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"
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,22 @@ At runtime it needs `ssh` on your `PATH` and nothing more. Trusting the https ce
command your OS already has — `certutil`, `security`, or `update-ca-certificates` — and
`ssh-browser trust` prints the one for your platform.

### Starting it without thinking about it

```
ssh-browser autostart
```

Registers the daemon to start when you log in, and says what it wrote and where. `--off` takes
it back out. No administrator rights on any of the three: a file in your Startup folder on
Windows, a launchd agent in your own `LaunchAgents` on macOS, a systemd user unit on Linux.

This is not a convenience. A bookmark that resolves only after you remember to start something
is not a bookmark, and the daemon is not a program anyone wants to run — it is what makes a URL
work. Unlike `trust`, which prints a command and executes nothing because trusting a root
changes what the whole machine believes, this one does the thing: starting a program of your
own at login is what you just asked for.

A host on its own means that account's home directory, which the daemon asks the remote
for. `ssh-browser hosts` prints what your `~/.ssh/config` already knows how to reach —
user, hostname, port and any `ProxyJump` — which is the list worth picking an alias from.
Expand Down
Loading