diff --git a/README.md b/README.md index 75b1813..33c92c8 100644 --- a/README.md +++ b/README.md @@ -224,10 +224,17 @@ channel in watch mode. It inverts the icon PNGs in `dist/` and appends ` (dev)` to the extension name, so the toolbar tile, the popup header, and the `chrome://extensions` -row all say which install you are looking at. Nothing else changes — same ID, -same key, same OAuth redirect URI. `npm run build` and the release packaging -are unaffected; the inverted tiles live in `brand/icons-dev/` and never reach -`public/`. See `scripts/dev-channel.mjs`. +row all say which install you are looking at. It also stamps the commit into +`version_name` — `1.4.0+2e9cb0e`, or `1.4.0+2e9cb0e-dirty` when the tree has +uncommitted changes — which is what the popup footer and +`chrome://extensions` then show, so a reloaded install says which build it is +running. `version` itself stays a plain dotted number, the only thing Chrome +accepts there. + +Nothing else changes — same ID, same key, same OAuth redirect URI. `npm run +build` and the release packaging are unaffected: they emit no `version_name`, +so the store popup shows the bare version, and the inverted tiles live in +`brand/icons-dev/` and never reach `public/`. See `scripts/dev-channel.mjs`. ## Sign in @@ -354,7 +361,7 @@ the CloudFront cache. Release trees are never deleted by a deploy; prune old ```sh npm run check # typecheck npm run build # popup/offscreen/background build + content-script IIFE build -npm run build:dev # same build, dev channel (inverted icons, " (dev)" name) +npm run build:dev # same build, dev channel (inverted icons, " (dev)" name, commit stamp) npm run verify:dist # check dist/manifest.json and that every referenced file exists npm run package # build + verify:dist in one step npm run package:dev # build:dev + verify:dist in one step diff --git a/scripts/dev-channel.mjs b/scripts/dev-channel.mjs index 70ca667..8efb4b8 100644 --- a/scripts/dev-channel.mjs +++ b/scripts/dev-channel.mjs @@ -4,7 +4,7 @@ // Chrome is happy to run both; what is missing is a way to see which toolbar // tile and which row in chrome://extensions is which. // -// Two changes, both applied to dist/ after the build: +// Three changes, all applied to dist/ after the build: // // - the icon PNGs are replaced with the inverted ones from brand/icons-dev/ // (rendered by scripts/render-icons.mjs). Overwriting the files rather than @@ -12,11 +12,14 @@ // else pointing at /icons/... follows along. // - the manifest name gets a suffix, which is what chrome://extensions, the // extensions menu, and the default action tooltip show. +// - `version_name` records the commit the build came from, which the popup +// footer shows in place of the bare version. // // Nothing here touches public/, so a production build cannot pick it up. // Turn it on with SUBSTANDARD_CHANNEL=dev (npm run dev sets it by default); // vite.config.ts calls applyDevChannel from a writeBundle hook. +import { execFileSync } from 'node:child_process' import { copyFileSync, readFileSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' @@ -26,20 +29,51 @@ export const DEV_ICON_SIZES = [16, 32, 48, 128] export const DEV_NAME_SUFFIX = ' (dev)' /** - * The dev channel's manifest. Only the name changes: the `key` stays, so the - * extension ID stays, so the OAuth redirect URI registered for the dev ID - * keeps working. Applying it twice is the same as applying it once. + * The dev channel's manifest. The `key` stays, so the extension ID stays, so + * the OAuth redirect URI registered for the dev ID keeps working. Applying it + * twice is the same as applying it once. + * + * `build` (a short sha, or undefined outside a git checkout) goes in + * `version_name`, not in `version`: Chrome only accepts up to four + * dot-separated integers there and refuses to load anything else. Both are + * built from `manifest.version`, so a rebuild at a new commit replaces the + * stamp instead of appending to it. */ -export function devManifest(manifest) { +export function devManifest(manifest, build) { const name = manifest.name.endsWith(DEV_NAME_SUFFIX) ? manifest.name : manifest.name + DEV_NAME_SUFFIX - return { ...manifest, name } + const dev = { ...manifest, name } + if (build) dev.version_name = `${manifest.version}+${build}` + return dev } /** - * Rewrite a built dist/ in place for the dev channel. Returns the new name, - * for the build log. + * The commit this build came from, `abc1234` or `abc1234-dirty`, or undefined + * if git cannot say — a tarball, or a checkout with no commits yet. The dev + * channel is a local build, so uncommitted work is the normal case and the + * sha alone would claim more than the build can back up. + */ +export function buildStamp(root) { + // stderr is dropped: "not a git repository" is an answer here, not a fault + // worth printing on every rebuild. + const git = (...args) => + execFileSync('git', args, { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + try { + const sha = git('rev-parse', '--short', 'HEAD') + return git('status', '--porcelain') ? `${sha}-dirty` : sha + } catch { + return undefined + } +} + +/** + * Rewrite a built dist/ in place for the dev channel. Returns the new name + * and version_name, for the build log. */ export function applyDevChannel(root, dist) { for (const size of DEV_ICON_SIZES) { @@ -49,7 +83,7 @@ export function applyDevChannel(root, dist) { ) } const file = join(dist, 'manifest.json') - const manifest = devManifest(JSON.parse(readFileSync(file, 'utf8'))) + const manifest = devManifest(JSON.parse(readFileSync(file, 'utf8')), buildStamp(root)) writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`) - return manifest.name + return manifest } diff --git a/scripts/dev-channel.test.mjs b/scripts/dev-channel.test.mjs index b43614a..2133270 100644 --- a/scripts/dev-channel.test.mjs +++ b/scripts/dev-channel.test.mjs @@ -1,8 +1,9 @@ import { readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { DEV_ICON_SIZES, DEV_NAME_SUFFIX, devManifest } from './dev-channel.mjs' +import { DEV_ICON_SIZES, DEV_NAME_SUFFIX, buildStamp, devManifest } from './dev-channel.mjs' import { invertHexColors, pngSize } from './render-icons.mjs' const root = join(import.meta.dirname, '..') @@ -20,8 +21,35 @@ describe('devManifest', () => { }) it('is idempotent — a watch rebuild patches an already-patched manifest', () => { - const once = devManifest(manifest) - expect(devManifest(once)).toEqual(once) + const once = devManifest(manifest, 'abc1234') + expect(devManifest(once, 'abc1234')).toEqual(once) + }) + + it('stamps the build into version_name, leaving version loadable', () => { + const dev = devManifest(manifest, 'abc1234-dirty') + expect(dev.version_name).toBe(`${manifest.version}+abc1234-dirty`) + // Chrome parses `version` itself: up to four integers, nothing else. + expect(dev.version).toBe(manifest.version) + expect(dev.version).toMatch(/^\d+(\.\d+){0,3}$/) + }) + + it('restamps rather than appends when the same dist/ is rebuilt at a new commit', () => { + const moved = devManifest(devManifest(manifest, 'abc1234'), 'def5678') + expect(moved.version_name).toBe(`${manifest.version}+def5678`) + }) + + it('leaves version_name off when git cannot name the build', () => { + expect(devManifest(manifest)).not.toHaveProperty('version_name') + }) +}) + +describe('buildStamp', () => { + it('is a short sha for this checkout, marked when the tree is dirty', () => { + expect(buildStamp(root)).toMatch(/^[0-9a-f]{7,}(-dirty)?$/) + }) + + it('is undefined outside a git checkout, rather than failing the build', () => { + expect(buildStamp(tmpdir())).toBeUndefined() }) }) diff --git a/src/popup/popup.ts b/src/popup/popup.ts index 23759b9..a7bf452 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -773,7 +773,9 @@ $('feedback-form').addEventListener('submit', async (e) => { // The running build, read from the manifest rather than baked in at build // time, so it is the version Chrome actually installed and can be read back -// in a bug report. -$('version').textContent = `v${chrome.runtime.getManifest().version}` +// in a bug report. Dev-channel builds carry the commit in version_name +// ("1.4.0+abc1234"); store builds have no version_name and show the version. +const manifest = chrome.runtime.getManifest() +$('version').textContent = `v${manifest.version_name ?? manifest.version}` init() diff --git a/vite.config.ts b/vite.config.ts index 299c357..91d435b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,9 +6,10 @@ import { configDefaults, defineConfig } from 'vitest/config' // @ts-expect-error plain-node script, no type declarations import { applyDevChannel } from './scripts/dev-channel.mjs' -// SUBSTANDARD_CHANNEL=dev builds the dev channel: inverted icons and a -// suffixed name, so an unpacked install is distinguishable from the store one -// in the same browser. See scripts/dev-channel.mjs. +// SUBSTANDARD_CHANNEL=dev builds the dev channel: inverted icons, a suffixed +// name, and the commit stamped into version_name, so an unpacked install is +// distinguishable from the store one in the same browser and says which build +// it is. See scripts/dev-channel.mjs. function devChannel(): Plugin | undefined { if (process.env.SUBSTANDARD_CHANNEL !== 'dev') return undefined return { @@ -19,8 +20,9 @@ function devChannel(): Plugin | undefined { applyToEnvironment: (environment) => environment.name === 'client', writeBundle(options) { const dist = options.dir ?? resolve(__dirname, 'dist') - const name = applyDevChannel(__dirname, dist) - console.log(`[dev-channel] ${dist}: icons inverted, name "${name}"`) + const { name, version_name } = applyDevChannel(__dirname, dist) + const build = version_name ? `, build ${version_name}` : '' + console.log(`[dev-channel] ${dist}: icons inverted, name "${name}"${build}`) }, } }