Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
7.4 kB · 163 lines
Shell
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164#!/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<version> 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 cinpm run checknpm 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)"fiif [[ "$(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 distexport SUBSTANDARD_CHANNEL=releasenpm run buildnpm 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 releasezipfile="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, zipfileout = 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"))PYfi
# 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, zipfileprint("\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, zipfilemanifest = json.loads(zipfile.ZipFile(sys.argv[1]).read("manifest.json"))sys.exit(1 if "key" in manifest else 0)PYpython3 - "$abs_zipfile" <<'PY' || die "$zipfile is a dev-channel build — its name carries the \" (dev)\" suffix"import json, sys, zipfilemanifest = 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
echoecho "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"