From 6854522ad58c6cf645b1b57cccf2037e0eeebd0f Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 03:40:41 +0000 Subject: [PATCH] fix(release): stop reporting a gated Chrome publish as success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chrome job caught the 'Publish condition not met' error, downgraded it to a warning and exited 0. The run went green, the version was never submitted, and the store quietly kept serving an older build — v0.8.37 and v0.8.39 both sat as unsubmitted drafts this way. Chrome invalidates the permission justifications whenever the manifest's permission set changes, so this fires on most releases that touch permissions. The gate now fails the job with the dashboard steps in the error, and each store writes its real outcome to the run summary so a green tick is no longer the only signal. Adds a daily Verify Published Versions workflow that asks the Chrome update server and AMO what they actually serve and fails when Chrome trails the newest tag, plus docs/CHROME_PRIVACY_PRACTICES.md with ready-to-paste justifications for every permission in the manifest. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MrHcQXSjCeCcAKhWPZg8w9 --- .github/workflows/release.yml | 34 ++++++--- .github/workflows/verify-published.yml | 89 +++++++++++++++++++++++ docs/CHROME_PRIVACY_PRACTICES.md | 99 ++++++++++++++++++++++++++ docs/RELEASE_PIPELINE.md | 22 ++++++ 4 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/verify-published.yml create mode 100644 docs/CHROME_PRIVACY_PRACTICES.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8891e48..b93ef6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -179,21 +179,36 @@ jobs: STATUS=$(echo "$RESPONSE" | jq -r '.status[0] // empty') ERROR_MSG=$(echo "$RESPONSE" | jq -r '.error.message // empty') + VERSION="${{ needs.build.outputs.version }}" + if [[ "$STATUS" == "OK" || "$STATUS" == "PUBLISHED_WITH_FRICTION_WARNING" || "$STATUS" == "PENDING_REVIEW" ]]; then - echo "Chrome extension v${{ needs.build.outputs.version }} submitted (status: $STATUS)" + echo "Chrome extension v$VERSION submitted (status: $STATUS)" + echo "- **Chrome**: v$VERSION submitted for review (\`$STATUS\`)" >> "$GITHUB_STEP_SUMMARY" exit 0 fi - # The new package is uploaded as a draft regardless of this step. Some - # publish failures are one-time account-level gates the owner must clear - # in the Chrome dashboard (e.g. the mandatory "Privacy practices" form). - # Those shouldn't fail the release — the draft is ready to submit — so - # warn loudly and pass. Genuinely unexpected errors still fail. + # The package uploads fine and then Google refuses to submit it. The + # usual cause is the Privacy practices form: Chrome invalidates the + # permission justifications whenever the manifest's permission set + # changes, and only a human in the dashboard can clear that. + # + # This branch used to `exit 0` with a warning. It must not. A release + # that reaches no user is a failed release, and the green tick is + # exactly how v0.8.37 and v0.8.39 sat unpublished — CI said success + # while the store served an older version for weeks. if echo "$ERROR_MSG" | grep -qiE "publish condition not met|privacy"; then - echo "::warning title=Chrome publish gated::Draft v${{ needs.build.outputs.version }} uploaded but not submitted — clear the gate in the Chrome dashboard: $ERROR_MSG" - exit 0 + { + echo "- **Chrome**: :x: v$VERSION uploaded as a draft but **NOT submitted**" + echo " - \`$ERROR_MSG\`" + echo " - Fix: open , select MarkSyncr," + echo " complete the **Privacy practices** tab, then **Submit for review**." + echo " - Re-running this workflow will not clear it." + } >> "$GITHUB_STEP_SUMMARY" + echo "::error title=Chrome publish gated::v$VERSION uploaded as a draft but NOT submitted. Complete the Privacy practices tab at https://chrome.google.com/webstore/devconsole and submit for review. $ERROR_MSG" + exit 1 fi + echo "- **Chrome**: :x: v$VERSION publish failed (\`${STATUS:-no status}\`)" >> "$GITHUB_STEP_SUMMARY" echo "::error::Chrome publish failed with status: ${STATUS:-none}" echo "$RESPONSE" | jq '.statusDetail // .error // .' exit 1 @@ -246,6 +261,7 @@ jobs: --approval-timeout 0 echo "Firefox extension v${{ needs.build.outputs.version }} submitted to AMO" + echo "- **Firefox**: v${{ needs.build.outputs.version }} submitted to AMO (human review queue)" >> "$GITHUB_STEP_SUMMARY" edge-release: name: Publish to Edge Add-ons @@ -292,6 +308,7 @@ jobs: # failing the whole release. Refresh the EDGE_* secrets to re-enable. if [[ "$HTTP_CODE" == "401" || "$HTTP_CODE" == "403" ]]; then echo "::warning title=Edge publish skipped::Edge auth failed (HTTP $HTTP_CODE) — refresh EDGE_API_KEY/EDGE_CLIENT_ID secrets to re-enable Edge publishing." + echo "- **Edge**: :warning: skipped — credentials rejected (HTTP $HTTP_CODE); nothing shipped" >> "$GITHUB_STEP_SUMMARY" echo "skipped=true" >> "$GITHUB_OUTPUT" exit 0 fi @@ -361,3 +378,4 @@ jobs: fi echo "Edge extension v${{ needs.build.outputs.version }} submitted for review" + echo "- **Edge**: v${{ needs.build.outputs.version }} submitted for review" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/verify-published.yml b/.github/workflows/verify-published.yml new file mode 100644 index 0000000..3fea3ef --- /dev/null +++ b/.github/workflows/verify-published.yml @@ -0,0 +1,89 @@ +name: Verify Published Versions + +# CI going green is not evidence that anything reached a user. The Chrome +# publish can be gated after a successful upload, AMO sits in a human review +# queue, and Edge has been failing auth silently. This job asks the stores what +# they actually serve and complains when that trails the newest tag. + +on: + schedule: + - cron: "17 9 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + name: Compare stores against the newest tag + runs-on: ubuntu-latest + steps: + - name: Load environment variables + env: + ENV_CONTENT: ${{ secrets.ENV_FILE }} + run: | + while IFS='=' read -r key value; do + [[ -z "$key" || "$key" =~ ^# ]] && continue + [[ -n "$value" ]] && echo "::add-mask::$value" + echo "${key}=${value}" >> "$GITHUB_ENV" + done <<< "$ENV_CONTENT" + + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Resolve the newest released version + id: tag + run: | + TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) + if [[ -z "$TAG" ]]; then + echo "::error::No release tag found" + exit 1 + fi + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + echo "Newest tag: $TAG" + + - name: Ask the stores what they serve + env: + RELEASED: ${{ steps.tag.outputs.version }} + run: | + behind=0 + + # Chrome: the update server is the only honest source — it returns the + # version real browsers will be handed, not the dashboard's draft. + CHROME_XML=$(curl -sS --max-time 30 \ + "https://clients2.google.com/service/update2/crx?response=updatecheck&prodversion=140.0&acceptformat=crx2,crx3&x=id%3D${CHROME_EXTENSION_ID}%26uc" || true) + # Anchor to the element: a bare ` version="` also matches + # the XML declaration's version="1.0" and silently reports that instead. + CHROME_VER=$(printf '%s' "$CHROME_XML" | grep -oP ']*\sversion="\K[0-9][0-9.]*' | head -1) + + if [[ -z "$CHROME_VER" ]]; then + echo "::warning::Could not read the Chrome update server response" + echo "- **Chrome**: :grey_question: could not be read" >> "$GITHUB_STEP_SUMMARY" + elif [[ "$CHROME_VER" == "$RELEASED" ]]; then + echo "- **Chrome**: :white_check_mark: serving $CHROME_VER" >> "$GITHUB_STEP_SUMMARY" + else + behind=1 + { + echo "- **Chrome**: :x: serving **$CHROME_VER**, newest tag is **$RELEASED**" + echo " - A draft may be uploaded but unsubmitted. Check the **Privacy practices**" + echo " tab at ." + } >> "$GITHUB_STEP_SUMMARY" + echo "::error title=Chrome is behind::Store serves $CHROME_VER but $RELEASED is tagged" + fi + + # AMO review is a human queue, so lagging is normal and only worth a note. + AMO_VER=$(curl -sS --max-time 30 \ + "https://addons.mozilla.org/api/v5/addons/addon/marksyncr/" \ + | jq -r '.current_version.version // empty' || true) + + if [[ -z "$AMO_VER" ]]; then + echo "- **Firefox**: :grey_question: could not be read" >> "$GITHUB_STEP_SUMMARY" + elif [[ "$AMO_VER" == "$RELEASED" ]]; then + echo "- **Firefox**: :white_check_mark: serving $AMO_VER" >> "$GITHUB_STEP_SUMMARY" + else + echo "- **Firefox**: :hourglass: serving $AMO_VER, newest tag is $RELEASED (review queue)" >> "$GITHUB_STEP_SUMMARY" + echo "::warning title=AMO is behind::AMO serves $AMO_VER but $RELEASED is tagged" + fi + + exit $behind diff --git a/docs/CHROME_PRIVACY_PRACTICES.md b/docs/CHROME_PRIVACY_PRACTICES.md new file mode 100644 index 0000000..475367f --- /dev/null +++ b/docs/CHROME_PRIVACY_PRACTICES.md @@ -0,0 +1,99 @@ +# Chrome Web Store: Privacy practices + +Chrome invalidates the permission justifications on the store listing whenever +the manifest's permission set changes. Until they are re-filled, the publish API +refuses to submit the uploaded draft: + +> Publish condition not met: To publish your item, you must provide mandatory +> privacy information in the new Developer Dashboard. + +The upload still succeeds, so the version sits as an unsubmitted draft. This is +what happened to v0.8.37 and again to v0.8.39. + +**Only a human can clear it.** Re-running the release workflow will not. + +## Clearing it + +1. Open +2. Select **MarkSyncr** (item `hjcjjcpialiakkalcgadnfnoomdaegjg`) +3. Go to the **Privacy practices** tab +4. Fill the single purpose, the justification for every permission below, and + the data-usage disclosures +5. **Save draft**, then **Submit for review** + +Then confirm it actually shipped — the dashboard and CI both lie about this: + +``` +curl -s "https://clients2.google.com/service/update2/crx?response=updatecheck&prodversion=140.0&acceptformat=crx2,crx3&x=id%3Dhjcjjcpialiakkalcgadnfnoomdaegjg%26uc" +``` + +The `version="..."` attribute is what real browsers get handed. The daily +`Verify Published Versions` workflow runs the same check. + +## Drafts to paste + +These are drawn from `apps/extension/src/manifest.chrome.json` at v0.8.39. +**Read each one before pasting it** — they have to be true of the build you are +submitting, and Google rejects justifications that overstate or understate what +the code does. + +### Single purpose + +> MarkSyncr keeps a user's browser bookmarks synchronised across browsers and +> devices, using a storage backend the user chooses: their own GitHub +> repository, Dropbox, Google Drive, or MarkSyncr Cloud. + +### Permission justifications + +| Permission | Justification | +| --- | --- | +| `bookmarks` | Read and write the user's bookmarks. This is the extension's core function: bookmarks are read to upload them to the chosen backend, and written to apply changes synced from another device. | +| `storage` | Store the user's sync settings, the selected storage provider, and locally cached sync state. Vault entries are stored only as ciphertext. | +| `alarms` | Schedule the periodic background sync and the vault auto-lock timer. Alarms are used rather than timers so both survive the service worker being shut down. | +| `notifications` | Tell the user the outcome of a background sync they cannot see — a completed sync, a sync conflict, or a failed upload — and notify when a site is blocked. | +| `identity` | Run the OAuth sign-in flow for the storage backends (GitHub, Dropbox, Google Drive) via `chrome.identity.launchWebAuthFlow`, so credentials are never typed into the extension. | +| `declarativeNetRequest` | Apply the bundled static rulesets that block advertising and tracking requests, and block known phishing and malware domains. Declarative rules are used so no browsing data is exposed to the extension. | +| `activeTab` | Read the title and URL of the current tab, and only when the user clicks the MarkSyncr toolbar button, so that page can be bookmarked. | + +### Host permission justifications + +| Host | Justification | +| --- | --- | +| `https://api.github.com/*` | Read and write the user's bookmark file in their own GitHub repository, when GitHub is the selected backend. | +| `https://api.dropboxapi.com/*`, `https://content.dropboxapi.com/*` | Read and write the user's bookmark file in Dropbox, when Dropbox is the selected backend. | +| `https://www.googleapis.com/*` | Read and write the user's bookmark file in Google Drive, when Drive is the selected backend. | +| `https://*.supabase.co/*` | MarkSyncr Cloud's backend: account authentication, bookmark sync, and storage of end-to-end encrypted vault ciphertext. | +| `https://marksyncr.com/*` | The MarkSyncr web app, for the sign-in handoff and account management. | +| `https://hole.cert.pl/*` | Download the CERT Polska phishing domain list used by the phishing blocker. | +| `https://raw.githubusercontent.com/*` | Download filter-list updates (EasyList, EasyPrivacy) for the ad and tracker blocker. | + +`http://localhost:3000/*` appears in the source manifest for development and is +stripped by `scripts/build.js` before packaging, so it is not in the submitted +ZIP and needs no justification. Confirm this in the build log — it prints +`Stripped 1 localhost host permission(s)`. + +### Data usage + +Check these against the code rather than against this table; the disclosures are +a legal statement, not a formality. + +- **Authentication information** — collected. OAuth tokens for the chosen + backend, and MarkSyncr Cloud session tokens. +- **Personally identifiable information** — collected. The account email address, + for MarkSyncr Cloud accounts. +- **Website content** — collected. Bookmark titles and URLs, which are the thing + being synced. +- **Not** collected: health, financial, location, personal communications, web + browsing activity (the blockers are declarative and observe no browsing). + +Also tick the three certifications: data is not sold to third parties, is not +used for purposes unrelated to the single purpose above, and is not used to +determine creditworthiness or for lending. + +## A standing risk worth naming + +The listing now bundles three things a reviewer may not read as one purpose: +bookmark sync, a credential vault, and an ad/phishing blocker. Chrome's +single-purpose policy is enforced unevenly, but this shape is the kind that +draws a rejection. If review starts bouncing, splitting the blocker or the vault +into its own listing is the usual remedy. diff --git a/docs/RELEASE_PIPELINE.md b/docs/RELEASE_PIPELINE.md index a3f64a2..f952256 100644 --- a/docs/RELEASE_PIPELINE.md +++ b/docs/RELEASE_PIPELINE.md @@ -149,10 +149,32 @@ The tag version doesn't match `manifest.chrome.json`. Run `pnpm version:bump` an ### Chrome: upload succeeds but publish fails +**Most often this is the Privacy practices form, not a policy violation.** Chrome +invalidates the permission justifications whenever the manifest's permission set +changes, and then refuses to submit the uploaded draft with +`Publish condition not met`. The release job fails on this deliberately — a +version nobody can install is not a release. See +[CHROME_PRIVACY_PRACTICES.md](./CHROME_PRIVACY_PRACTICES.md) for the drafts to +paste and the steps to clear it. Re-running the workflow will not help. + +Otherwise: + - The extension may have policy violations flagged by automated review - Check the [Developer Dashboard](https://chrome.google.com/webstore/devconsole/) for details - `PUBLISHED_WITH_FRICTION_WARNING` is treated as success (warnings are informational) +### Confirming a release actually reached users + +A green release run means the API accepted the upload, nothing more. Ask the +Chrome update server what it serves: + +```bash +curl -s "https://clients2.google.com/service/update2/crx?response=updatecheck&prodversion=140.0&acceptformat=crx2,crx3&x=id%3Dhjcjjcpialiakkalcgadnfnoomdaegjg%26uc" +``` + +The `Verify Published Versions` workflow runs this daily and fails when Chrome +trails the newest tag. Run it on demand from the Actions tab. + ### Firefox: web-ext sign fails - Verify `FIREFOX_JWT_ISSUER` and `FIREFOX_JWT_SECRET` at https://addons.mozilla.org/en-US/developers/addon/api/key/