#!/usr/bin/env bash # Package a store-uploadable release of the extension: # preflight checks, clean build, zip of dist/ contents. Requires the release # tag v to already exist at HEAD — version decisions and tagging # live outside this script. Nothing is pushed or uploaded; that is the # human's step. set -euo pipefail cd "$(dirname "$0")/.." die() { echo "deploy-ext: error: $*" >&2 exit 1 } # Where the packaged zip has to be uploaded by hand. cws_console="https://chrome.google.com/webstore/devconsole/" # Print a URL as an OSC 8 terminal hyperlink so it can be clicked straight # from the release output. Only when stdout is a real terminal: piped or # redirected output (CI logs, `tee`) gets the bare URL instead of escapes. link() { local url="$1" if [[ -t 1 && -n "${TERM:-}" && "$TERM" != dumb ]]; then printf '\e]8;;%s\e\\%s\e]8;;\e\\\n' "$url" "$url" else printf '%s\n' "$url" fi } # 1. Preflight: clean tree, dependencies from the lockfile, typecheck, tests. # The dirty-tree check stays first: `npm ci` deletes and reinstalls # node_modules, so nothing destructive happens before the tree is known good. if [[ -n "$(git status --porcelain)" ]]; then git status --short >&2 die "working tree is dirty; commit or stash before releasing" fi # Package the dependencies the tag pins, not whatever this machine happens to # have installed: nothing else in this script installs, so the artifact was a # function of the state of node_modules. `npm ci` installs exactly # package-lock.json, and refuses (rather than rewriting the lockfile) when # package.json and the lockfile disagree — so that drift stops the release # instead of quietly changing what ships. node_modules is gitignored, so this # cannot dirty the tree the check above just cleared. npm ci npm run check npm test # The extension bundles oauth/client-metadata.json and validates its own # runtime redirect URI against it, so metadata that has drifted from the ids # the extension ships under breaks sign-in for those installs only — with no # server-side symptom. --hosted also requires the copy a PDS reads to match. node scripts/check-oauth-metadata.mjs --hosted # 2. Version: this script makes no version decisions. It requires that # package.json and the manifest agree, and that the release tag for that # version already exists and points at HEAD. version="$(node -p "JSON.parse(require('fs').readFileSync('public/manifest.json','utf8')).version")" pkg_version="$(node -p "JSON.parse(require('fs').readFileSync('package.json','utf8')).version")" [[ -n "$version" && "$version" != "undefined" ]] || die "could not read version from public/manifest.json" if [[ "$version" != "$pkg_version" ]]; then die "version drift: public/manifest.json has $version but package.json has $pkg_version — align them by hand (this script does not auto-stamp)" fi tag="v$version" if ! git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then die "no tag $tag — create the release tag first (version decisions live outside this script)" fi if [[ "$(git rev-parse "$tag^{commit}")" != "$(git rev-parse HEAD)" ]]; then die "tag $tag does not point at HEAD — packaging this tree would mislabel the artifact" fi # 3. Clean production build: no watch-mode leftovers, and never the dev # channel (inverted icons and a " (dev)" name) even if the shell exports it. rm -rf dist export SUBSTANDARD_CHANNEL=release npm run build npm run verify:dist # A regression guard, not a live gate: the welcome page's words are written # now, but this repo leaves the brand's prose to a human (see CLAUDE.md), so # a new or rewritten page starts out carrying this token again. Nothing else # would stop it reaching store users, since every build, test and load of a # page works perfectly well with it in. placeholders="$(grep -rl SNICKERSNEE dist || true)" if [[ -n "$placeholders" ]]; then echo "$placeholders" >&2 die "dist/ still carries placeholder copy — write the welcome page's words before releasing" fi # The artifact itself, not the sources it should have come from: a rebuild # from a stale tree passes every check above and still ships the wrong # redirect URIs (this is what happened to v1.2.1). node scripts/check-oauth-metadata.mjs --dist dist # 4. Strip the "key" field from the packaged manifest: the Chrome Web Store # rejects uploads that carry one ("key field is not allowed in manifest"). # The key is a dev-only convenience that pins the unpacked ID; the store # assigns the published extension its own ID on first upload, and that ID's # redirect URI must then be added to oauth/client-metadata.json. node -e ' const fs = require("fs"); const m = JSON.parse(fs.readFileSync("dist/manifest.json", "utf8")); delete m.key; fs.writeFileSync("dist/manifest.json", JSON.stringify(m, null, 2) + "\n"); ' # 5. Zip the CONTENTS of dist/ at the archive root (Chrome Web Store wants # manifest.json at the top level, not nested in a dist/ folder). mkdir -p release zipfile="release/substandard-$tag.zip" rm -f "$zipfile" abs_zipfile="$PWD/$zipfile" if command -v zip >/dev/null; then (cd dist && zip -q -r -X "$abs_zipfile" .) else # Fallback: python3's zipfile module, no extra dependencies. python3 - "$abs_zipfile" <<'PY' import os, sys, zipfile out = sys.argv[1] with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: for root, dirs, files in os.walk("dist"): for f in files: path = os.path.join(root, f) z.write(path, os.path.relpath(path, "dist")) PY fi # Sanity-check the archive: manifest at the root with no "key" field, # no private key, no dotfiles, and the sourcemaps a reviewer reads. entries="$(python3 - "$abs_zipfile" <<'PY' import sys, zipfile print("\n".join(zipfile.ZipFile(sys.argv[1]).namelist())) PY )" grep -qx 'manifest.json' <<<"$entries" || die "$zipfile has no manifest.json at its root" python3 - "$abs_zipfile" <<'PY' || die "$zipfile manifest still has a \"key\" field — the store rejects it" import json, sys, zipfile manifest = json.loads(zipfile.ZipFile(sys.argv[1]).read("manifest.json")) sys.exit(1 if "key" in manifest else 0) PY python3 - "$abs_zipfile" <<'PY' || die "$zipfile is a dev-channel build — its name carries the \" (dev)\" suffix" import json, sys, zipfile manifest = json.loads(zipfile.ZipFile(sys.argv[1]).read("manifest.json")) sys.exit(1 if manifest["name"].endswith(" (dev)") else 0) PY ! grep -q 'key\.pem' <<<"$entries" || die "$zipfile contains key.pem — never ship the private key" ! grep -Eq '(^|/)\.' <<<"$entries" || die "$zipfile contains dotfiles" # Sourcemaps are shipped on purpose: the store reviews minified code more # slowly, and this extension's all-hosts grant already earns an in-depth # review. Losing them silently would give that back, so require the ones a # reviewer needs — every entry point we wrote. for js in background.js popup.js content.js offscreen.js welcome.js; do grep -qx "$js.map" <<<"$entries" || die "$zipfile has no $js.map — reviewers would be reading minified output" done echo echo "deploy-ext: done" echo " artifact: $zipfile" echo " tag: $tag (at HEAD)" echo "Next steps (yours, not this script's):" echo " - if $tag is not pushed yet: git push origin $tag" echo " - upload $zipfile to the Chrome Web Store developer dashboard:" echo -n " " link "$cws_console"