Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 153 additions & 10 deletions .github/workflows/publish-extension.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,80 @@
name: Publish Extension

# The release kickoff is a tag push and nothing else:
# git tag vX.Y.Z && git push origin vX.Y.Z
# The tag is the source of truth for the version. validate enforces strict
# semver, forward-only versioning, and that the tagged commit is on main;
# publish syncs the version files to the tag before building, so the store
# always receives the tagged version even if the repo files lag; release
# mirrors the CHANGELOG section into a GitHub release; sync-versions opens a
# PR when the repo files lagged the tag.

on:
push:
tags:
- 'v*.*.*'

concurrency:
group: publish-extension
cancel-in-progress: false

jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.semver.outputs.version }}
steps:
- name: Checkout with full history and tags
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Tag is strict semver
id: semver
run: |
if ! echo "$GITHUB_REF_NAME" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Tag '$GITHUB_REF_NAME' is not vMAJOR.MINOR.PATCH. Pre-release and partial tags are not published."
exit 1
fi
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"

- name: Tag only moves forward
run: |
prev=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -vx "$GITHUB_REF_NAME" | sort -V | tail -1)
if [ -z "$prev" ]; then
echo "No previous release tag; $GITHUB_REF_NAME is the first."
exit 0
fi
highest=$(printf '%s\n%s\n' "$prev" "$GITHUB_REF_NAME" | sort -V | tail -1)
if [ "$highest" != "$GITHUB_REF_NAME" ]; then
echo "::error::$GITHUB_REF_NAME does not sort above the highest existing tag ($prev). Versions only go up; delete nothing, tag higher."
exit 1
fi
echo "$GITHUB_REF_NAME sorts above $prev"

- name: Tagged commit is on main
run: |
git fetch origin main --quiet
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "::error::Tagged commit $GITHUB_SHA is not on main. Merge to main first, then tag."
exit 1
fi

- name: Changelog has a section for this version
run: |
if ! grep -q "^## \[${GITHUB_REF_NAME#v}\]" CHANGELOG.md; then
echo "::warning::CHANGELOG.md has no '## [${GITHUB_REF_NAME#v}]' section. The GitHub release will fall back to a changelog link."
fi

publish:
needs: validate
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
drift: ${{ steps.sync.outputs.drift }}
steps:
- name: Checkout
uses: actions/checkout@v7
Expand All @@ -19,19 +84,19 @@ jobs:
with:
node-version: "24"

- name: Verify versions in repo
run: node scripts/verify-version.cjs

- name: Verify tag matches version
- name: Sync version files to the tag
id: sync
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
PACKAGE_VERSION=$(node -p "require('./package.json').version")
MANIFEST_VERSION=$(node -p "require('./public/manifest.json').version")
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ] || [ "$TAG_VERSION" != "$MANIFEST_VERSION" ]; then
echo "Tag version ($TAG_VERSION) does not match package.json ($PACKAGE_VERSION) or public/manifest.json ($MANIFEST_VERSION)."
exit 1
out=$(node scripts/set-version.cjs "${{ needs.validate.outputs.version }}")
echo "$out"
if echo "$out" | grep -q 'versions-updated'; then
echo "drift=true" >> "$GITHUB_OUTPUT"
echo "::warning::package.json / public/manifest.json lagged tag ${GITHUB_REF_NAME}. Building with the tag version; a sync PR will be opened."
fi

- name: Verify versions in workspace
run: node scripts/verify-version.cjs

- uses: pnpm/action-setup@v6
with:
run_install: false
Expand All @@ -52,12 +117,23 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Test
run: pnpm test

- name: Build extension
run: pnpm build

- name: Verify package archive
run: test -f app.zip

- name: Built manifest matches the tag
run: |
zipver=$(unzip -p app.zip manifest.json | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>console.log(JSON.parse(d).version))")
if [ "$zipver" != "${{ needs.validate.outputs.version }}" ]; then
echo "::error::app.zip manifest version ($zipver) does not match the tag (${{ needs.validate.outputs.version }})."
exit 1
fi

- name: Upload & publish
uses: mnao305/chrome-extension-upload@v6.0.0
with:
Expand All @@ -66,3 +142,70 @@ jobs:
client-id: ${{ secrets.CHROME_CLIENT_ID }}
client-secret: ${{ secrets.CHROME_CLIENT_SECRET }}
refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }}

release:
needs: [validate, publish]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Extract changelog section
run: |
VERSION="${{ needs.validate.outputs.version }}"
awk -v ver="$VERSION" '
/^## \[/ { if (found) exit; if (index($0, "## [" ver "]") == 1) { found = 1; next } }
found { print }
' CHANGELOG.md > release-notes.md
if ! [ -s release-notes.md ]; then
echo "See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/main/CHANGELOG.md)." > release-notes.md
fi

- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
echo "Release $GITHUB_REF_NAME already exists; leaving it untouched."
else
gh release create "$GITHUB_REF_NAME" --verify-tag \
--title "$GITHUB_REF_NAME" \
--notes-file release-notes.md
fi

sync-versions:
needs: [validate, publish]
if: needs.publish.outputs.drift == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout main
uses: actions/checkout@v7
with:
ref: main

- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: "24"

- name: Open version sync PR
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ needs.validate.outputs.version }}"
BRANCH="chore/sync-version-v${VERSION}"
git switch -c "$BRANCH"
node scripts/set-version.cjs "$VERSION"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json public/manifest.json
git commit -s -m "chore(release): sync versions to ${VERSION}"
git push -u origin "$BRANCH"
gh pr create --base main --head "$BRANCH" \
--title "chore(release): sync versions to ${VERSION}" \
--body "Tag v${VERSION} was published while package.json / public/manifest.json still carried an older version. The store received the tagged version (the publish job builds from the tag), so this PR only brings the repo files back in line. Note: PRs opened by the Actions token do not trigger CI; push an empty commit or close and reopen if you want checks to run."
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ The document is sharded across sync keys — head `v2`, chunks `v2x_0..12`, meta
## Scripts (package.json)
- `pnpm test`: Run Vitest tests (`test:coverage` / `test:coverage:summary` for coverage runs).
- `pnpm verify-version`: Check `package.json` and `public/manifest.json` versions match.
- `pnpm set-version <X.Y.Z>`: Write a version into both files (release prep; also used by the publish workflow).
- `pnpm lint`: Run Biome checks.
- `pnpm lint:fix`: Run Biome with auto-fix.
- `pnpm develop`: Vite build in watch mode.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ if (copyButtonEl) {
## CI/CD Details

- CI runs on PRs: lint, type check, test (Node 22 + 24 matrix)
- Publish runs on `v*.*.*` tag push: builds + uploads `app.zip` to Chrome Web Store
- Publish runs on `v*.*.*` tag push: validates (strict semver, forward-only, tag on main), syncs version files to the tag, tests + builds + uploads `app.zip`, creates the GitHub release from the changelog, opens a version-sync PR on drift
- Secrets needed: `CHROME_EXTENSION_ID`, `CHROME_CLIENT_ID`, `CHROME_CLIENT_SECRET`, `CHROME_REFRESH_TOKEN`

## Deep Dives
Expand Down
27 changes: 23 additions & 4 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,29 @@ type(scope)!: subject

## Release Process

1. Update version in both `package.json` and `public/manifest.json`
2. Run `pnpm verify-version` to confirm parity
3. Tag as `vX.Y.Z` and push the tag
4. Publish workflow builds and uploads `app.zip` to Chrome Web Store
The kickoff is a tag push and nothing else:

1. Land everything for the release on `main` via PRs, including a
`## [X.Y.Z]` section in `CHANGELOG.md` (it becomes the GitHub release
notes). Bump versions with `pnpm set-version X.Y.Z` in the release PR.
2. `git tag vX.Y.Z <commit-on-main> && git push origin vX.Y.Z`

The publish workflow then:

- **validates**: strict semver (`vMAJOR.MINOR.PATCH` only), the new tag must
sort above every existing release tag (versions only go up), and the tagged
commit must be on `main`
- **publishes**: syncs `package.json` + `public/manifest.json` to the tag
version in the build workspace (`scripts/set-version.cjs`), tests, builds,
verifies the version inside `app.zip`, and uploads to the Chrome Web Store
- **releases**: creates the GitHub release from the tag's `CHANGELOG.md`
section (falls back to a changelog link if the section is missing)
- **sync-versions**: if the repo files lagged the tag, opens a PR bringing
them back in line (the store still received the tagged version)

A bad tag (non-semver, lower than an existing tag, or off-main) fails in
`validate` and nothing is published. Tags are never moved or deleted; to fix
a mistake, tag the next higher version.

## Adding Code

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"build": "vite build && node build.cjs",
"type:check": "tsc --noEmit",
"verify-version": "node scripts/verify-version.cjs",
"set-version": "node scripts/set-version.cjs",
"prepare": "husky",
"check-updates": "pnpm ncu -i"
},
Expand Down
41 changes: 41 additions & 0 deletions scripts/set-version.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2023-2026 Jared M. Scott. This work is licensed under the
* Creative Commons Attribution-ShareAlike 4.0 International License. To view
* a copy of this license, visit https://creativecommons.org/licenses/by-sa/4.0/
*/
const fs = require('fs');
const path = require('path');

const version = process.argv[2];

if (!/^\d+\.\d+\.\d+$/.test(version || '')) {
console.error('Usage: node scripts/set-version.cjs <MAJOR.MINOR.PATCH>');
process.exit(1);
}

const root = process.env.VERIFY_VERSION_ROOT
? path.resolve(process.env.VERIFY_VERSION_ROOT)
: path.resolve(__dirname, '..');

let changed = false;

for (const rel of ['package.json', path.join('public', 'manifest.json')]) {
const filePath = path.join(root, rel);
const raw = fs.readFileSync(filePath, 'utf8');
const current = JSON.parse(raw).version;
if (current === version) {
continue;
}
// Replace only the version value so the rest of the file keeps its exact
// formatting (JSON.stringify would reflow inline arrays like "permissions").
const updated = raw.replace(/("version"\s*:\s*")\d+\.\d+\.\d+(")/, `$1${version}$2`);
if (JSON.parse(updated).version !== version) {
console.error(`${rel}: could not rewrite the version field.`);
process.exit(1);
}
console.log(`${rel}: ${current} -> ${version}`);
fs.writeFileSync(filePath, updated);
changed = true;
}

console.log(changed ? 'versions-updated' : 'versions-unchanged');