Article · May 22, 2026

Chrome extension version-bump discipline: pre-commit + GitHub Actions

The Chrome Web Store rejects re-uploads with the same manifest version. Catch the missed bump in pre-commit; gate it again in CI.

Every Chrome extension update that goes to the Web Store has to bump its manifest.json version field. Forget the bump, and the submission is rejected with “the version of the uploaded package is not later than the published version.” That is a wasted submission, a rebuild, a re-upload, and a 1 to 3 day wait for re-review. It happens once per developer career; the goal is to make it happen exactly that once.

Two-layer guard. Pre-commit hook locally for fast feedback. GitHub Actions in CI for the gate that survives git commit --no-verify. This post ships both.

Why the Web Store requires monotonic versions

The Chrome Web Store update policy requires that every published version is strictly greater than the previous published version. The reasons:

  • Caching. Chrome caches the current extension version per user. A re-upload with the same version would not trigger an update on already-installed clients.
  • Audit trail. The Web Store’s review history is keyed by version. Re-using a version makes the history ambiguous.
  • Rollback. Google rolls back to a known version when a release is problematic. Re-used versions break the rollback model.

The version string is compared as four dot-separated integers (e.g. 1.2.3.4). The simplest pattern is semantic versioning (MAJOR.MINOR.PATCH); the Web Store does not care which scheme you use as long as each release is strictly greater than the last.

The pre-commit hook (bash plus jq, copy-paste)

The hook runs on every git commit, checks if any file under extension/ was staged for commit, and if so, verifies extension/manifest.json is also in the staged diff. If the extension folder changed without a manifest version bump, the hook fails the commit.

#!/usr/bin/env bash
# .githooks/pre-commit
# Block commits that modify extension/ without bumping extension/manifest.json version.

set -euo pipefail

# Files staged for this commit
STAGED=$(git diff --cached --name-only)

# Did any file under extension/ change?
EXT_CHANGED=$(echo "$STAGED" | grep -E '^extension/' || true)
if [ -z "$EXT_CHANGED" ]; then
  # No extension changes, nothing to check
  exit 0
fi

# Was extension/manifest.json staged?
MANIFEST_STAGED=$(echo "$STAGED" | grep -E '^extension/manifest\.json$' || true)
if [ -z "$MANIFEST_STAGED" ]; then
  echo "✗ extension/ changed but extension/manifest.json was not staged."
  echo "  Bump the version field in extension/manifest.json before committing."
  exit 1
fi

# Was the "version" field actually changed in the staged diff?
STAGED_DIFF=$(git diff --cached extension/manifest.json)
if ! echo "$STAGED_DIFF" | grep -E '^\+\s*"version"' > /dev/null; then
  echo "✗ extension/manifest.json was staged but the version field did not change."
  echo "  Bump the version field before committing."
  exit 1
fi

echo "✓ Extension version bump detected."

Save as .githooks/pre-commit, mark executable (chmod +x .githooks/pre-commit), and configure git to use the .githooks/ directory:

git config core.hooksPath .githooks

The hook runs in milliseconds. It fires before the commit is created, so a failed check leaves the working tree intact and the commit aborts cleanly. The developer fixes the version and re-runs git commit.

Installing pre-commit hooks across a team

The catch: hooks live in .git/hooks/ by default, and .git/ is not committed to the repo. A new clone has no hooks. Two patterns solve this.

Option 1: a shared .githooks/ folder + a manual git config.

# Once per clone
git config core.hooksPath .githooks

The .githooks/ folder is in the repo, so the hook scripts ship with the project. The git config is per-clone and has to be run manually. The pattern I prefer because it has no dependencies.

Option 2: Husky (or a similar hook-manager).

npm install --save-dev husky
npx husky install
echo 'bash .githooks/pre-commit' > .husky/pre-commit
chmod +x .husky/pre-commit

Husky’s prepare script (which it adds to package.json automatically) configures core.hooksPath to .husky/ on every npm install. New clones get the hooks automatically when they run npm install. The downside is the dependency on Husky (one more npm package, one more thing that can break across Node versions).

For a small team (1 to 3 developers), the manual git config is fine. For larger teams, Husky is worth the dependency.

A diagram showing the two-layer guard. On the left, a developer machine with the pre-commit hook icon and a green checkmark labeled "fast feedback, milliseconds." On the right, a GitHub Actions runner with the CI workflow icon and a red shield labeled "gate that cannot be skipped." An arrow between them shows that --no-verify bypasses the left layer but not the right.

The GitHub Actions workflow (the gate that survives --no-verify)

The pre-commit hook is fast feedback. It is also bypassable: git commit --no-verify skips all hooks. A developer in a hurry, a teammate who does not know about the convention, an automated tool that does not respect hooks, all skip the check.

The CI gate is the actual lock. It runs on every PR, cannot be bypassed at commit time, and blocks merge if the check fails.

# .github/workflows/extension-version-check.yml
name: Extension version check

on:
  pull_request:
    paths:
      - 'extension/**'

jobs:
  check-version-bump:
    runs-on: ubuntu-latest
    steps:
      - name: Check out PR branch
        uses: actions/checkout@v5
        with:
          fetch-depth: 0  # Need full history to compare against base

      - name: Compute base SHA
        id: base
        run: echo "sha=$(git merge-base origin/${{ github.base_ref }} HEAD)" >> $GITHUB_OUTPUT

      - name: Verify manifest.json version increased
        run: |
          BASE_VERSION=$(git show ${{ steps.base.outputs.sha }}:extension/manifest.json | jq -r .version)
          HEAD_VERSION=$(cat extension/manifest.json | jq -r .version)

          echo "Base version: $BASE_VERSION"
          echo "Head version: $HEAD_VERSION"

          if [ "$BASE_VERSION" = "$HEAD_VERSION" ]; then
            echo "✗ extension/manifest.json version did not change between base and head."
            echo "  Bump the version before merging."
            exit 1
          fi

          # Parse as dot-separated integers and compare lexicographically by part
          if [ "$(printf '%s\n%s\n' "$BASE_VERSION" "$HEAD_VERSION" | sort -V | head -n 1)" != "$BASE_VERSION" ]; then
            echo "✗ Head version ($HEAD_VERSION) is not greater than base version ($BASE_VERSION)."
            echo "  The Chrome Web Store requires monotonically increasing versions."
            exit 1
          fi

          echo "✓ Version bumped: $BASE_VERSION → $HEAD_VERSION"

Three things this does that the pre-commit hook does not:

It compares against the base branch, not just the current commit’s staged diff. This catches the case where a developer commits the extension change without the version bump, then later (or in a follow-up commit) realizes the mistake and bumps in a separate commit. The cumulative diff against base is what matters for the Web Store; this CI check enforces that.

It verifies the version actually increased, not just that the field changed. The pre-commit hook checks for “a line starting with +"version" in the diff,” which catches the obvious case but not the rare case where someone accidentally decreased the version. The CI check uses sort -V to verify the head version is strictly greater.

It runs on every PR regardless of how the commit was made. Hook-skipping is no longer a path; the only way past the CI check is to actually bump the version.

Handling the edge cases

Two cases trip up the simple version of the check.

Case 1: a PR has no extension changes. The workflow’s on.pull_request.paths filter means the workflow only runs when files under extension/ are in the PR diff. A PR that touches only src/ or docs/ does not trigger the workflow at all. No false positives.

Case 2: a PR has extension changes spread across multiple commits. The CI check compares base-to-head, not commit-by-commit. If a PR has three commits and the version bump is in the third, the check passes. If the version was bumped in the first commit and then reverted in the second, the cumulative diff has no version change and the check fails. Correct behavior in both cases.

Case 3: the PR was force-pushed and the base SHA is no longer in the branch’s history. The fetch-depth: 0 in the checkout step pulls full history, which avoids the “shallow clone cannot find base” error. Slow on huge repos; fine for any normal project.

Why both layers matter

A common question: “if the CI gate catches everything the hook catches, why have the hook?”

Two reasons.

Latency. The pre-commit hook fails in milliseconds. The CI gate fails in 30 to 90 seconds (the time to spin up a runner, clone the repo, run the check). For a developer mid-flow, the hook gives you the failure immediately, while the context is fresh. The CI gate gives you the failure on the next coffee break, with three other PRs to context-switch through first.

Asymmetric skip cost. The hook is opt-out (--no-verify). The CI gate is opt-in to skip (you have to actively change the workflow or push directly to main). The combination means the typical case (developer respects the hook) has fast feedback, and the abnormal case (developer skips the hook) has a slower but unmissable gate.

The pattern generalizes. Any time you have a check that is cheap to run locally and expensive to run in CI, you want both. The local check is the productivity layer; the CI check is the safety layer.

Other places this pattern applies

The same two-layer guard works for any “the artifact has to be in a specific state to ship” check:

  • npm version for published packages. npm rejects re-publishes of the same version. Same pattern, same hook structure, replace extension/manifest.json with package.json.
  • Docker image tags. A docker push to a tag that already exists overwrites silently (depending on registry policy). Use a tag-bump hook + CI check the same way.
  • Database migrations. A migration file with the same timestamp as an existing one is ambiguous. A pre-commit check that compares timestamps in supabase/migrations/ catches this. CI verifies on PR.
  • Translation files. If you have i18n JSON files that must all have the same key set, a pre-commit hook + CI check enforces the parity.

The discipline carries. The version check for Chrome extensions is one example; the pattern is “if forgetting it costs you a wasted submission, gate it twice.”

Where this fits in the Chrome extension lifecycle

The version-bump check is the third post in this series on shipping Chrome extensions. The first, shipping a Manifest V3 Chrome extension to the Web Store, covered the publication gates. The second, building a Chrome extension popup with Supabase Auth, covered the auth setup that most internal extensions need.

This post is the discipline that keeps the extension shippable across updates. Without it, the first update after launch is the one that gets rejected for a missed bump and resets your release cadence by a week. With it, the version field is part of every PR and never silently drifts.

If you are running a Chrome extension on the Web Store and the team has bumped the version manually so far, let’s talk. The two-layer guard is a 30-minute setup that saves a wasted-submission cycle per quarter and removes one item from the release checklist forever.