diff --git a/.github/workflows/publish-extension.yml b/.github/workflows/publish-extension.yml index bbd434e..a1b0ddc 100644 --- a/.github/workflows/publish-extension.yml +++ b/.github/workflows/publish-extension.yml @@ -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 @@ -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 @@ -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: @@ -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." diff --git a/AGENTS.md b/AGENTS.md index 908d41d..9ed3bb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `: 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. diff --git a/CLAUDE.md b/CLAUDE.md index 220ae31..99c8032 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/development.md b/docs/development.md index fbbc842..6bf2365 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 && 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 diff --git a/package.json b/package.json index e28977b..f95fc5d 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/set-version.cjs b/scripts/set-version.cjs new file mode 100644 index 0000000..dca901f --- /dev/null +++ b/scripts/set-version.cjs @@ -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 '); + 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');