From 9c2bb908d229a2df883f62eba07900c5a1a95f3d Mon Sep 17 00:00:00 2001 From: gololdf1sh Date: Wed, 30 Sep 2026 15:19:46 +0300 Subject: [PATCH] fix(release): a Web Store zip beside the GitHub one, and one extension ID for both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Web Store refuses a manifest with a `key` ("key field is not allowed in manifest"), and the release packed extension/ as it is, so no release could go to the store. It also packed whatever sat in the folder — a host's handoff.json, with its credentials, included. The release now publishes two zips from the same tree: testomat-io-.zip as before, and testomat-io--webstore.zip, whose manifest has no key. Neither takes handoff.json. Before Publish, a new step opens both and fails the run unless they hold the same files, byte for byte, apart from the key. The key in the repo becomes the store item's public key, so a GitHub copy answers to the store's ID, amcnjlghmkkjfaajanfeghpgbjakdcka, instead of mdjanaamdkpmnobcmoedleaiaifpnlbg: a store copy and a GitHub copy stop being two extensions in one Chrome. Testeiya hashes the installed manifest's key, so it follows by itself. Check the manifest now fails unless the key gives that ID, and fails on an update_url, which marks a store install and would stop a GitHub copy reading the host's file. The install guide names the zip to take. A GitHub copy updated to this release is a new extension to Chrome: connect and pin it again, and let unsent results go out first. Probed by running the workflow's own run: blocks on a copy of the repo with a handoff.json planted: both zips, 158 entries each, no handoff.json, the GitHub key giving the store ID. A planted update_url, the old key, a Pack that keeps handoff.json in one zip or both, or one that leaves the key in the store manifest each fail the run before Publish. In Chromium the GitHub zip loads as amcnjlghmkkjfaajanfeghpgbjakdcka and the store zip without a key. Closes #391 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 64 ++++++++++++++++++++++++++++++++--- docs/guide/install.md | 3 +- extension/manifest.json | 2 +- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ea842a..21361d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,13 +31,14 @@ jobs: echo "version=${VERSION%%-*}" echo "version_name=$VERSION" echo "zip=testomat-io-$VERSION.zip" + echo "store_zip=testomat-io-$VERSION-webstore.zip" [[ "$VERSION" == *-* ]] && echo "prerelease=true" || echo "prerelease=false" } >> "$GITHUB_OUTPUT" - name: Check the manifest run: | python3 - <<'PY' - import json, pathlib, sys + import base64, hashlib, json, pathlib, sys root = pathlib.Path('extension') try: @@ -45,6 +46,19 @@ jobs: except json.JSONDecodeError as e: sys.exit(f"::error::manifest.json is not valid JSON: {e}") + # The key is the Web Store item's public key, so a GitHub copy answers to the store's ID. + STORE_ID = 'amcnjlghmkkjfaajanfeghpgbjakdcka' + try: + digest = hashlib.sha256(base64.b64decode(m['key'], validate=True)).hexdigest()[:32] + except (KeyError, TypeError, ValueError): + sys.exit("::error::manifest.json has no readable key, so every unpacked copy would get its own ID") + got_id = ''.join(chr(97 + int(c, 16)) for c in digest) + if got_id != STORE_ID: + sys.exit(f"::error::manifest.json's key gives the ID {got_id}, not the Web Store item's {STORE_ID}") + # update_url marks a store install, and a copy carrying it never reads a host's handoff.json. + if 'update_url' in m: + sys.exit("::error::manifest.json carries update_url, which only the Web Store may add") + refs = list((m.get('icons') or {}).values()) refs += list(((m.get('action') or {}).get('default_icon') or {}).values()) for path in ((m.get('side_panel') or {}).get('default_path'), @@ -92,8 +106,47 @@ jobs: - name: Pack run: | set -euo pipefail - (cd extension && zip -qr "../${{ steps.v.outputs.zip }}" . -x '*.DS_Store') + # A host's handoff.json holds its credentials: it never ships, in either zip. + (cd extension && zip -qr "../${{ steps.v.outputs.zip }}" . -x '*.DS_Store' -x 'handoff.json') + # The Web Store refuses a manifest with a key: it assigns the item's ID itself. + rm -rf webstore && cp -R extension webstore && rm -f webstore/handoff.json + python3 - <<'PY' + import json, pathlib + + p = pathlib.Path('webstore/manifest.json') + m = json.loads(p.read_text()) + del m['key'] + p.write_text(json.dumps(m, indent=2, ensure_ascii=False) + '\n') + PY + (cd webstore && zip -qr "../${{ steps.v.outputs.store_zip }}" . -x '*.DS_Store') unzip -l "${{ steps.v.outputs.zip }}" | tail -1 + unzip -l "${{ steps.v.outputs.store_zip }}" | tail -1 + + - name: Check the two zips + env: + ZIP: ${{ steps.v.outputs.zip }} + STORE_ZIP: ${{ steps.v.outputs.store_zip }} + run: | + python3 - <<'PY' + import json, os, sys, zipfile + + github, store = zipfile.ZipFile(os.environ['ZIP']), zipfile.ZipFile(os.environ['STORE_ZIP']) + names = sorted(github.namelist()) + if names != sorted(store.namelist()): + diff = sorted(set(names) ^ set(store.namelist())) + sys.exit(f"::error::the two zips hold different files: {', '.join(diff)}") + if 'handoff.json' in names: + sys.exit("::error::a host's handoff.json is in the zips") + mg, ms = json.loads(github.read('manifest.json')), json.loads(store.read('manifest.json')) + if 'key' in ms or 'update_url' in ms: + sys.exit("::error::the Web Store manifest carries key or update_url") + if {k: v for k, v in mg.items() if k != 'key'} != ms: + sys.exit("::error::the two manifests differ by more than the key") + differ = [n for n in names if n != 'manifest.json' and github.read(n) != store.read(n)] + if differ: + sys.exit(f"::error::these files differ between the zips: {', '.join(differ)}") + print(f"{len(names)} entries in each zip; the Web Store one lacks only the manifest key") + PY - name: Publish env: @@ -101,12 +154,13 @@ jobs: run: | set -euo pipefail TAG='${{ steps.v.outputs.tag }}' + ZIPS=('${{ steps.v.outputs.zip }}' '${{ steps.v.outputs.store_zip }}') # Publishing from the Releases UI makes tag and release together, so the # existence check can lose that race — a failed create means it appeared. if gh release view "$TAG" >/dev/null 2>&1; then - gh release upload "$TAG" '${{ steps.v.outputs.zip }}' --clobber - elif ! gh release create "$TAG" '${{ steps.v.outputs.zip }}' \ + gh release upload "$TAG" "${ZIPS[@]}" --clobber + elif ! gh release create "$TAG" "${ZIPS[@]}" \ --title "$TAG" --generate-notes \ ${{ steps.v.outputs.prerelease == 'true' && '--prerelease' || '' }}; then - gh release upload "$TAG" '${{ steps.v.outputs.zip }}' --clobber + gh release upload "$TAG" "${ZIPS[@]}" --clobber fi diff --git a/docs/guide/install.md b/docs/guide/install.md index f7f1f7e..9909f39 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -8,7 +8,8 @@ your disk, once, and Chrome keeps it. Chrome 123 or newer. 1. Get the folder — either way works: - download the newest `testomat-io-.zip` from [Releases](https://github.com/testomatio/browser-extension/releases) — - the single file under **Assets** — and unpack it, **or** + the file under **Assets** without `-webstore` in its name (that one is + the Chrome Web Store upload) — and unpack it, **or** ![The zip under Assets on the Releases page](img/install-release-zip.png) diff --git a/extension/manifest.json b/extension/manifest.json index 8ce824f..0923f89 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -50,5 +50,5 @@ "permissions": ["storage", "sidePanel", "debugger", "scripting", "webRequest", "tabCapture", "offscreen", "contextMenus"], "host_permissions": [""], - "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoqlLbtaIjeOwOUDDkGbTb05aCcYnS5CMqNqCW1WBQrB61jzlqzG3wY9y1MAoXCS77kZKSgkCW/VF8WSx23UA5EWzNKph4ZrxdOxvUPMD5ScUKoCNWZkPTvP163wm17w+mCeT6UItKze09WeVzuotbj/7PgNBWGKgzYk1VLMDu+0ZHfEH/yjP1E5hYkxGkB+5f8rLc0bB/MM4iG+8gWTkFlHXlFQp3xEkRui05kO03Z8M5+VMrBeWL6VHIZioOAecTj/+xYLNFb73Y+fl0TrkdwtPFXng7viad8l9HtBovv2KQz5ckKp1yY/mJZdr17bmhZhlOuVSyUewnOqfzkgJtQIDAQAB" + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoNO5FmLZNVum8CahrRGHtqGXQUI/D8AU9CpCKuAgRV+hRDYu+IYNeF62isIeLL3g6iHoeQtEAjbujj6g4he90DV+0UlUw4zanyXmSynvCHZMCyKDnqu7wg6LujuuYLfJY7utUvMKtWC64dUx5E5Z8nxhz3tU+snfmYaClyie6ai+gHfXelDM1Pass06KE/xgKiwZeIVTJPsOrkzXff2PAMEzVE9PYTRKqcG4Bm38pJb64ptKi0355F8zfSsQxvtldBuOEAZQAbACfUXZWcxZqWrQVGA5PMasYRPlQxqC4PTcwXep1TISYH1BKqA3tP2I7Nb2QvXpKxHypHMvOPHtlwIDAQAB" }