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
117 changes: 117 additions & 0 deletions .github/workflows/store.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
name: store

# Upload a release to the Chrome Web Store, and publish it.
#
# What this can and cannot do, because the line is not where you would guess:
#
# - It cannot create the listing. The Store listing and Privacy tabs have to be filled in the
# dashboard by a person, and the extension ID does not exist until they have been.
# Description, screenshots, category, the data disclosure — none of them are reachable from
# this API. The first submission is by hand and always will be.
# - After that it can do every update: upload the package and publish it.
#
# The human gate is not removed, it moves. Merging the release PR is the decision; a release is
# what triggers this. Nothing reaches anybody that was not merged first.
#
# A service account rather than a refresh token. Refresh tokens issued by an OAuth consent
# screen still in "Testing" expire in a week, which makes a release pipeline that worked in
# March fail in April for a reason nobody changed.

on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Release tag to upload, e.g. v0.5.0"
required: true
type: string
publish:
description: "Publish after uploading, rather than leaving it as a draft"
type: boolean
default: false

permissions:
contents: read

jobs:
store:
name: upload to the Chrome Web Store
runs-on: ubuntu-latest
steps:
- name: Refuse early if this has never been set up
# Before the token exchange, so a missing secret is named rather than arriving as an
# authentication error twenty lines into a log.
env:
KEY: ${{ secrets.CWS_SERVICE_ACCOUNT }}
PUBLISHER: ${{ secrets.CWS_PUBLISHER_ID }}
ITEM: ${{ secrets.CWS_EXTENSION_ID }}
run: |
missing=
[ -n "$KEY" ] || missing="$missing CWS_SERVICE_ACCOUNT"
[ -n "$PUBLISHER" ] || missing="$missing CWS_PUBLISHER_ID"
[ -n "$ITEM" ] || missing="$missing CWS_EXTENSION_ID"
if [ -n "$missing" ]; then
echo "::error::missing repository secrets:$missing — see extension/STORE.md"
exit 1
fi

- id: auth
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
with:
credentials_json: ${{ secrets.CWS_SERVICE_ACCOUNT }}
token_format: access_token
access_token_scopes: https://www.googleapis.com/auth/chromewebstore

- name: Fetch the release package
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag || github.event.release.tag_name }}
REPO: ${{ github.repository }}
run: |
gh release download "$TAG" --repo "$REPO" --pattern '*.zip' --dir .
# Exactly one, or the shell picks. Two zips on a release would otherwise mean
# uploading whichever sorted first.
count=$(ls -1 ./*.zip | wc -l)
test "$count" -eq 1 || { echo "::error::expected one zip, found $count"; exit 1; }
mv ./*.zip package.zip
unzip -p package.zip manifest.json | grep '"version"'

- name: Upload
env:
TOKEN: ${{ steps.auth.outputs.access_token }}
PUBLISHER: ${{ secrets.CWS_PUBLISHER_ID }}
ITEM: ${{ secrets.CWS_EXTENSION_ID }}
run: |
# `--fail-with-body` rather than `--fail`: the store says why in the body, and a bare
# "exit 22" is the least useful half of that.
curl --silent --show-error --fail-with-body \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/zip" \
--data-binary @package.zip \
"https://chromewebstore.googleapis.com/upload/v2/publishers/$PUBLISHER/items/$ITEM:upload" \
| tee upload.json
# The HTTP status can be 200 while the item state is FAILURE, so the body decides.
grep -q '"uploadState"[[:space:]]*:[[:space:]]*"SUCCESS"' upload.json

- name: Publish
# A release publishes; a manual run does so only when asked. The button defaults to the
# cautious answer because somebody pressing it is usually testing the pipeline.
if: ${{ github.event_name == 'release' || inputs.publish }}
env:
TOKEN: ${{ steps.auth.outputs.access_token }}
PUBLISHER: ${{ secrets.CWS_PUBLISHER_ID }}
ITEM: ${{ secrets.CWS_EXTENSION_ID }}
run: |
# Visibility is whatever the dashboard already says; this API does not set it. If it
# was changed by hand, the store refuses until it has been published by hand once at
# the new visibility — so a failure here is worth reading rather than retrying.
curl --silent --show-error --fail-with-body \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Length: 0" \
"https://chromewebstore.googleapis.com/v2/publishers/$PUBLISHER/items/$ITEM:publish" \
| tee publish.json
# Review is not instant. This says it was accepted, not that it is live.
echo "submitted; the store reviews it before anyone sees it"
67 changes: 66 additions & 1 deletion extension/STORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,32 @@ the entry that would raise the question.

**Remote code**

Answered **no** on the form. Not an obvious no, so the reasoning is written here rather than
left in somebody's memory of a decision made while filling in a text box.

> None. Everything the extension executes is in the uploaded package.

What makes it a question: `applyPac` fetches `http://127.0.0.1:<port>/proxy.pac` from the
user's own daemon and hands the text to `chrome.proxy` as `pacScript.data`. Chrome's definition
of remotely hosted code is "anything that is executed by the browser that is loaded from
someplace other than the extension's own files", and a PAC is JavaScript.

Why it is still no:

- It does not run in the extension. `chrome.proxy` is a first-party API whose documented input
is a PAC string, and the browser's network stack evaluates it — no page, no service worker,
no `eval`.
- It is not remotely hosted. It comes from loopback, from a program the user installed and
started. Nothing on the network can serve it and no update of ours can change it.
- The policy exists so that an extension's behaviour cannot be changed after review by a server
its author controls. Nobody controls this one but the person running the extension.

If a reviewer disagrees, the fix is small and already scoped: the PAC is a function of the
suffix and the port, and the extension knows both. Generating it locally makes the answer
unambiguous and keeps what the fetch was for — the suffix stays data the daemon reports, so
changing it still needs no extension release. What would be lost is having one generator, and
an e2e check that runs both and compares their answers covers that.

## Data disclosure

Tick **one**. Under-declaring is a policy violation, so anything arguable is declared — but
Expand Down Expand Up @@ -151,10 +175,51 @@ the first run of this produced exactly that. `shots.yml` runs on a fresh runner
throwaway sshd and the invented `ssh_config` in `e2e/shots-config/`, and `shots.mjs` refuses
any host but a local one so it cannot happen by habit.

## Still to do by hand
## The first submission, by hand

Once. The API cannot create a listing — description, screenshots, category and the data
disclosure are not reachable from it, and the extension ID does not exist until the Store
listing and Privacy tabs have been filled in.

1. Register the developer account. Five dollars, once.
2. Upload the zip, paste the text above, attach the screenshots.
3. Choose visibility. Unlisted is worth considering first: this extension does nothing without
a daemon installed separately, and a public listing collects installs from people who have
not done that and will reasonably report it as broken.

## Every release after that, by CI

`.github/workflows/store.yml` uploads the release's zip and publishes it. The decision is still
a person's — it is merging the release PR — but nothing is retyped, and what reaches the store
is the package that was built, tested and attached to the release rather than one somebody
dragged into a browser.

Three repository secrets, set once:

| secret | where it comes from |
|---|---|
| `CWS_EXTENSION_ID` | the item's ID, from its dashboard URL, once it exists |
| `CWS_PUBLISHER_ID` | Developer Dashboard → Account |
| `CWS_SERVICE_ACCOUNT` | the JSON key of a Google Cloud service account |

A service account rather than a refresh token: a refresh token issued while the OAuth consent
screen is still in "Testing" expires after a week, so the pipeline would work today and fail
next month having changed nothing.

1. In the Google Cloud console, create a project and enable the **Chrome Web Store API**.
2. Create a service account. It needs no roles.
3. Create a JSON key for it, and put the whole file in `CWS_SERVICE_ACCOUNT`.
4. In the Developer Dashboard, under **Account**, add the service account's email address.
Only one service account can be attached to a publisher, so this is the one.

Then `gh workflow run store.yml -f tag=v0.5.0` tries it without publishing, and after that
every published release goes on its own.

Two things that will bite:

- **Visibility is not set by this API.** The item publishes at whatever the dashboard says, and
if visibility is changed by hand the store refuses API publishing until it has been published
by hand once at the new setting.
- **A version cannot be uploaded twice.** `versions agree` in CI keeps `manifest.json` in step
with `Cargo.toml`; if that ever drifts, the store rejects the upload rather than quietly
taking it.
Binary file modified extension/icons/icon-128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 22 additions & 6 deletions extension/make-icons.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,21 @@ const OUT = join(here, "icons");

/// The four Chrome asks for: 16 in the toolbar, 48 on the extensions page, 128 in the store,
/// and 32 for the displays that sit between them.
const SIZES = [16, 32, 48, 128];
///
/// The 128 is drawn smaller inside its square, and that is the store's rule rather than a
/// preference: the listing wants 96×96 of artwork with 16 transparent pixels on every side,
/// because it draws its own rounded frame around whatever it is given and a tile that fills
/// the canvas has that frame drawn across its corners. The others are toolbar icons, where the
/// opposite holds and padding is wasted pixels at sixteen across.
///
/// Still one drawing. `fill` is where it is sampled from, not a second set of numbers.
const SIZES = [
{ px: 16, fill: 1 },
{ px: 32, fill: 1 },
{ px: 48, fill: 1 },
// The tile is 93% of the canvas, so 96/128 of the canvas means 96/119 of the tile.
{ px: 128, fill: 96 / 119 },
];

/// Supersampling factor. Drawn straight at 16×16 the diagonals stair-step; drawn at 64×64 and
/// averaged down, the same shape has edges that read as smooth.
Expand Down Expand Up @@ -63,7 +77,7 @@ function ink(x, y) {
return Math.min(upper, lower, bar);
}

function render(size) {
function render(size, fill) {
const n = size * SS;
const px = new Uint8Array(size * size * 4);

Expand All @@ -77,8 +91,10 @@ function render(size) {
for (let sy = 0; sy < SS; sy += 1) {
for (let sx = 0; sx < SS; sx += 1) {
// The centre of each subpixel, mapped to -1..1.
const u = ((pxi * SS + sx + 0.5) / n) * 2 - 1;
const v = ((py * SS + sy + 0.5) / n) * 2 - 1;
// Divided by `fill`, so a smaller `fill` moves the sample further out and the
// drawing lands smaller in the same square. One drawing, sampled from further away.
const u = (((pxi * SS + sx + 0.5) / n) * 2 - 1) / fill;
const v = (((py * SS + sy + 0.5) / n) * 2 - 1) / fill;

if (roundedRect(u, v, 1.86, 1.86, 0.42) > 0) {
continue;
Expand Down Expand Up @@ -139,7 +155,7 @@ function png(size, pixels) {
}

await mkdir(OUT, { recursive: true });
for (const size of SIZES) {
await writeFile(join(OUT, `icon-${size}.png`), png(size, render(size)));
for (const { px: size, fill } of SIZES) {
await writeFile(join(OUT, `icon-${size}.png`), png(size, render(size, fill)));
console.log(` wrote icons/icon-${size}.png`);
}