Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://chrome.google.com/webstore/devconsole>, 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
89 changes: 89 additions & 0 deletions .github/workflows/verify-published.yml
Original file line number Diff line number Diff line change
@@ -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 <updatecheck> 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 '<updatecheck[^>]*\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 <https://chrome.google.com/webstore/devconsole>."
} >> "$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
99 changes: 99 additions & 0 deletions docs/CHROME_PRIVACY_PRACTICES.md
Original file line number Diff line number Diff line change
@@ -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 <https://chrome.google.com/webstore/devconsole>
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.
22 changes: 22 additions & 0 deletions docs/RELEASE_PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
Loading