From f9efc85040f709849499d026d37853954b229739 Mon Sep 17 00:00:00 2001 From: JJ Date: Fri, 17 Jul 2026 12:28:23 +0800 Subject: [PATCH 1/2] refactor(release): one command releases every affected artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four near-identical scripts (422 lines) became one. Releasing a single packages/ui bugfix meant three commands, three pushes and three full CI cycles — each release commit moved HEAD, and every script re-gated on green CI for the new HEAD — plus knowing by heart that the dashboard and extension both bundle the SDK and so had to go too. `bun run release` now works out what's affected, shows the plan, asks once, and does the lot in one commit with one CI gate: → affected since last tags (patch): sdk 0.4.1 → 0.4.2 → sdk-v0.4.2 → npm dashboard 0.6.4 → 0.6.5 → v0.6.5 → Docker Hub extension 0.1.3 → 0.1.4 → extension-v0.1.4 → Web Store Affected is derived from what each artifact is BUILT FROM, bundled workspace packages included. Propagation then needs no special rule: the dashboard bakes the SDK IIFE into its image and the extension syncs it via sync-sdk.ts, so listing the SDK as a source is what makes a packages/ui fix reach all three — and expo, which ships its own React Native UI, correctly stays out of it. Fixes found while doing this, each having already caused a real failure: - Tags are annotated. amend_release_commit_and_retag() re-tagged with `git tag -f` (lightweight), while every script printed `git push --follow-tags` as the next step — which pushes annotated tags only. The tag silently never reached the remote and the publish workflow never fired. extension-v0.1.4 hit exactly this and needed a manual `git push origin ` to recover. - Detection ignores release commits. A release bumps its own package.json and CHANGELOG.md inside its own paths, so a plain `git diff` counts "chore(release): sdk-v0.4.2" as an SDK change and marks everything that bundles core affected — every release making the next one look necessary. - Push happens in one place. "postrelease": "git push --follow-tags" fired implicitly off `bun run release`, so the dashboard auto-pushed while its own script printed "Next: push" and the other three genuinely didn't. Both implicit hooks are gone; the script does its own verification. - Tags push one at a time: GitHub won't start workflow runs for tags beyond the third in a single push, and there are four artifacts. - release:all is deleted. It chained scripts with no push between them, so the second always died on `no CI run found for `. Version lines stay independent, so a dashboard change still can't churn a republish of @reprojs/core. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish-docker.yml | 5 +- .github/workflows/publish-npm.yml | 6 +- README.md | 26 ++- docs/development/index.md | 27 ++- package.json | 10 +- scripts/lib/scope-changelog.sh | 20 +- scripts/release-dashboard.sh | 94 --------- scripts/release-expo.sh | 108 ---------- scripts/release-extension.sh | 101 ---------- scripts/release-sdk.sh | 119 ----------- scripts/release.sh | 287 +++++++++++++++++++++++++++ 11 files changed, 339 insertions(+), 464 deletions(-) delete mode 100755 scripts/release-dashboard.sh delete mode 100755 scripts/release-expo.sh delete mode 100755 scripts/release-extension.sh delete mode 100755 scripts/release-sdk.sh create mode 100755 scripts/release.sh diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 85f97b51..5fb1a36f 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -31,8 +31,9 @@ env: BUN_VERSION: 1.3.13 jobs: - # Publish-time safety net. Mirrors `bun run prerelease` so a locally-stale - # release machine can't ship a broken image. + # Publish-time safety net. Mirrors the checks `bun run release` runs locally + # (check, sdk:build, test:sdk) so a locally-stale release machine can't ship + # a broken image. # # The full dashboard integration suite (Postgres + dev server boot) runs in # ci.yml on the `push: branches: [main]` event that precedes the tag push, diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 5c9291db..6b0495b9 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -7,8 +7,10 @@ name: Publish npm package # - Manual dispatch → republishes whatever is currently in packages/core/package.json # # Decoupled from the dashboard's v*.*.* tags so SDK + dashboard can cut -# releases on independent cadences. Use `bun run release:sdk` to bump -# packages/core and tag locally (it runs prerelease checks first). +# releases on independent cadences. `bun run release` (scripts/release.sh) +# bumps packages/core and tags locally; it releases every artifact affected by +# the change in one commit, so an SDK fix that the dashboard and extension +# bundle tags all three at once. # # Auth — uses npm Trusted Publishing (OIDC), not a token. # - The package's Trusted Publisher config on npmjs.com must match: diff --git a/README.md b/README.md index 1f0434bf..ddb71c1c 100644 --- a/README.md +++ b/README.md @@ -211,17 +211,27 @@ bun run test:sdk # SDK tests only (no Postgres required) Releases are driven by [changelogen](https://github.com/unjs/changelogen) from Conventional Commits. +One command releases everything affected by what's on `main`: + +```bash +bun run release # patch every affected artifact +bun run release --minor # level applies to all affected +bun run release --dry-run # print the plan, change nothing +bun run release --only sdk # restrict (comma-separated); --skip is the inverse +``` + +It works out which artifacts are affected by asking what each one is *built from* — including the SDK bundle that the dashboard bakes into its image and the extension syncs at build time. So a `packages/ui` fix releases the SDK, dashboard **and** extension together, while a dashboard-only change releases just the dashboard. It shows the plan, asks once, then makes a single commit, tags each artifact, and pushes. + +Version lines stay independent (`sdk-v0.4.2` / `v0.6.5` / `extension-v0.1.4`), so a dashboard change never forces a churn republish of `@reprojs/core`. + +Mixed bump levels compose with `--only`: + ```bash -bun run release # patch (default) — dashboard -bun run release:minor # minor — dashboard -bun run release:major # major — dashboard -bun run release:sdk # release the SDK packages -bun run release:extension # release the Chrome extension (builds + zips for the release page) -bun run release:all # release SDK + extension together (BUMP=minor to override) -bun run postrelease # push tags to GitHub +bun run release --minor --only sdk +bun run release --only dashboard,extension ``` -The `prerelease` hook runs lint, format:check, SDK build, and SDK tests. CI runs the full gate (including the dashboard integration tests against a real Postgres) on every PR and push to `main`. +The command runs lint, format:check, SDK build and SDK tests itself, and refuses to tag unless CI is already green on `main`. CI runs the full gate (including the dashboard integration tests against a real Postgres) on every PR and push to `main`. --- diff --git a/docs/development/index.md b/docs/development/index.md index 0d8c18dc..92ac6e6f 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -130,16 +130,27 @@ reprojs/ Releases are driven by [changelogen](https://github.com/unjs/changelogen) from Conventional Commits. Dashboard, SDK, and the tester extension release on independent tag prefixes so they can version independently. ```bash -bun run release # dashboard — patch bump -bun run release:minor # dashboard — minor -bun run release:major # dashboard — major -bun run release:sdk # SDK packages (tag prefix: sdk-v) -bun run release:extension # tester extension (tag prefix: extension-v) — builds + zips artifacts -bun run release:all # SDK + extension together (set BUMP=minor|major to override) -bun run postrelease # push tags to GitHub +bun run release # patch every affected artifact +bun run release --minor # level applies to all affected +bun run release --dry-run # print the plan, change nothing +bun run release --only sdk # restrict (comma-separated); --skip is the inverse +bun run release --yes # skip the confirm prompt ``` -The `prerelease` hook runs lint, format:check, SDK build, and SDK tests. Pushing a `v*.*.*` tag triggers `publish-docker` which builds the multi-arch image and pushes to `ripwords/reprojs-dashboard`. Pushing an `sdk-v*.*.*` tag triggers `publish-npm` which publishes `@reprojs/core` with provenance. Pushing an `extension-v*.*.*` tag triggers `publish-extension` which uploads to the Chrome Web Store **and** creates a GitHub Release with the `repro-tester.zip` attached (what testers install unpacked while Web Store review is pending). +`scripts/release.sh` decides what's affected from what each artifact is *built from*, including bundled workspace packages: + +| Artifact | Tag prefix | Built from | +| --- | --- | --- | +| sdk | `sdk-v` | `packages/{core,ui,sdk-utils,shared,recorder}` | +| dashboard | `v` | `apps/dashboard`, `packages/integrations` **+ bundles the SDK** | +| extension | `extension-v` | `apps/extension` **+ bundles the SDK** | +| expo | `expo-v` | `packages/expo`, `packages/{sdk-utils,shared}` | + +Propagation needs no special rule: the dashboard bakes the SDK IIFE into its image and the extension syncs it via `sync-sdk.ts`, so listing the SDK as a source is what makes a `packages/ui` fix release all three. Expo deliberately omits `packages/ui` — it ships its own React Native UI. Release commits are excluded from detection, otherwise a release's own version bump would make the next one look necessary forever. + +Everything lands in **one commit** with one tag per artifact, gated once on green CI. Tags are annotated — lightweight tags are silently skipped by `git push --follow-tags`, which previously meant a tag never reached the remote and its publish workflow never fired. Tags are pushed one at a time because GitHub won't start workflow runs for tags beyond the third in a single push. + +Pushing a `v*.*.*` tag triggers `publish-docker` which builds the multi-arch image and pushes to `ripwords/reprojs-dashboard`. Pushing an `sdk-v*.*.*` tag triggers `publish-npm` which publishes `@reprojs/core` with provenance. Pushing an `extension-v*.*.*` tag triggers `publish-extension` which uploads to the Chrome Web Store **and** creates a GitHub Release with the `repro-tester.zip` attached (what testers install unpacked while Web Store review is pending). ## Where to file bugs / ideas diff --git a/package.json b/package.json index cfb17b12..382b9570 100644 --- a/package.json +++ b/package.json @@ -37,15 +37,7 @@ "docs:preview": "vitepress preview docs", "prepare": "husky", "fix": "bun run fmt && bun run lint:fix", - "prerelease": "bun run check && bun run sdk:build && bun run test:sdk", - "release": "bash ./scripts/release-dashboard.sh", - "release:minor": "bash ./scripts/release-dashboard.sh --minor", - "release:major": "bash ./scripts/release-dashboard.sh --major", - "postrelease": "git push --follow-tags", - "release:sdk": "bash ./scripts/release-sdk.sh", - "release:expo": "bash ./scripts/release-expo.sh", - "release:extension": "bash ./scripts/release-extension.sh", - "release:all": "bash ./scripts/release-sdk.sh ${BUMP:-patch} && bash ./scripts/release-expo.sh ${BUMP:-patch} && bash ./scripts/release-extension.sh ${BUMP:-patch}" + "release": "bash ./scripts/release.sh" }, "devDependencies": { "@types/bun": "^1.3.12", diff --git a/scripts/lib/scope-changelog.sh b/scripts/lib/scope-changelog.sh index 3e363440..49ff995e 100755 --- a/scripts/lib/scope-changelog.sh +++ b/scripts/lib/scope-changelog.sh @@ -57,16 +57,10 @@ filter_changelog_by_paths() { mv "${CHANGELOG}.tmp" "$CHANGELOG" rm -f "$sha_file" } - -# Amend the just-created release commit + recreate the tag at the new HEAD. -# changelogen creates the commit + tag atomically inside `--release`, so the -# only way to inject a CHANGELOG post-process is to amend after the fact and -# move the tag forward. -amend_release_commit_and_retag() { - local TAG="$1" - git add -A - if ! git diff --cached --quiet; then - git commit --amend --no-edit --no-verify >/dev/null - fi - git tag -f "$TAG" >/dev/null -} +# NOTE: amend_release_commit_and_retag() used to live here. It amended +# changelogen's commit and re-tagged with `git tag -f` — a LIGHTWEIGHT tag. +# Every release script then told you to run `git push --follow-tags`, which +# pushes annotated tags only, so those pushes silently no-op'd: no tag on the +# remote, no publish workflow, no error. release.sh now passes --no-commit +# --no-tag to changelogen and creates annotated tags itself, so there's +# nothing to amend. diff --git a/scripts/release-dashboard.sh b/scripts/release-dashboard.sh deleted file mode 100755 index 31352f82..00000000 --- a/scripts/release-dashboard.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env bash -# Bumps the root package version, runs prerelease checks, updates -# CHANGELOG.md, commits, and tags as vX.Y.Z. The tag push triggers -# .github/workflows/publish-docker.yml, which publishes the dashboard image -# to Docker Hub. -# -# Usage: -# bun run release # patch -# bun run release:minor -# bun run release:major -# -# Why this exists as a script instead of an inline `changelogen --release`: -# 1. changelogen's --from defaults to `git describe --tags --abbrev=0`, -# which returns the most recent reachable tag regardless of prefix. A -# recently-cut sdk-v* tag would become this release's base — that's -# how the v0.1.6 CHANGELOG entry got corrupted with a -# sdk-v0.1.6...v0.1.6 compare range. Pin --from to the last v*.*.* -# tag explicitly. -# 2. changelogen silently downgrades --minor and --major while on 0.x -# versions (minor → patch, major → minor) to discourage 0.x SemVer -# drift. We compute the bump ourselves and pass an explicit -r so -# `release:minor` actually means minor. - -set -euo pipefail - -# shellcheck source=lib/ci-gate.sh -. "$(dirname "$0")/lib/ci-gate.sh" - -BUMP_FLAG="${1:-}" # "", "--minor", or "--major" -case "$BUMP_FLAG" in - "") BUMP="patch" ;; - "--minor") BUMP="minor" ;; - "--major") BUMP="major" ;; - *) - echo "usage: $0 [--minor|--major]" >&2 - exit 2 - ;; -esac - -if [ -n "$(git status --porcelain)" ]; then - echo "error: working tree not clean. Commit or stash first." >&2 - git status --short >&2 - exit 1 -fi - -CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) -if [ "$CURRENT_BRANCH" != "main" ]; then - echo "error: release must run from main (currently on $CURRENT_BRANCH)" >&2 - exit 1 -fi - -require_green_ci - -echo "→ running prerelease checks..." -bun run prerelease - -# Version-sort, filter to v* (exclude sdk-v*). -LAST_DASHBOARD_TAG=$(git tag --list 'v*.*.*' --sort=-version:refname | head -n1) -if [ -z "$LAST_DASHBOARD_TAG" ]; then - echo "error: no v*.*.* tag found to use as --from base" >&2 - exit 1 -fi -echo "→ using $LAST_DASHBOARD_TAG as changelog base" - -# Compute the next version literally so changelogen's 0.x downgrade can't -# silently turn a `release:minor` into a patch. -CURRENT_VERSION=$(node -p "require('./package.json').version") -NEW_VERSION=$(node -e ' - const [M, m, pa] = process.argv[1].split(".").map(Number); - const bump = process.argv[2]; - const next = - bump === "major" ? [M + 1, 0, 0] : - bump === "minor" ? [M, m + 1, 0] : - [M, m, pa + 1]; - console.log(next.join(".")); -' "$CURRENT_VERSION" "$BUMP") -TAG="v${NEW_VERSION}" - -echo "→ bumping dashboard $CURRENT_VERSION → $NEW_VERSION via changelogen..." -# --no-github so the script only bumps + commits + tags. GitHub Release -# creation is owned by publish-docker.yml, which runs on the tag push: -# centralizing the gh release step in CI means a mid-flight local -# interrupt (pre-commit hook, Ctrl-C, connectivity blip) can't leave us -# with a tag but no release, the way v0.1.11/v0.1.12 did before. -bunx changelogen --release \ - -r "$NEW_VERSION" \ - --from "$LAST_DASHBOARD_TAG" \ - --no-github - -echo "" -echo "✓ tagged $TAG locally with CHANGELOG entry." -echo "" -echo "Next: push to trigger publish-docker.yml" -echo " git push --follow-tags" diff --git a/scripts/release-expo.sh b/scripts/release-expo.sh deleted file mode 100755 index db77e1a6..00000000 --- a/scripts/release-expo.sh +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env bash -# Bumps packages/expo's version, runs prerelease checks, writes -# packages/expo/CHANGELOG.md, commits, and tags as expo-vX.Y.Z. The tag push -# triggers .github/workflows/publish-expo.yml, which republishes @reprojs/expo -# from source with provenance and creates the GitHub Release. -# -# Usage: -# bun run release:expo [patch|minor|major] -# -# The Expo SDK is on its own release line separate from @reprojs/core (sdk-v*) -# and the dashboard (v*). Breaking changes in one don't force churn in the -# others. -# -# Bootstrap: before the very first release, `expo-v0.0.0` must exist as a -# base for changelogen's --from range. Cut it with: -# -# git tag expo-v0.0.0 $(git rev-list --max-parents=0 HEAD) -# git push origin expo-v0.0.0 -# -# …then run this script. - -set -euo pipefail - -# shellcheck source=lib/ci-gate.sh -. "$(dirname "$0")/lib/ci-gate.sh" -# shellcheck source=lib/scope-changelog.sh -. "$(dirname "$0")/lib/scope-changelog.sh" - -BUMP="${1:-patch}" -case "$BUMP" in - patch|minor|major) ;; - *) echo "usage: $0 [patch|minor|major]" >&2; exit 2 ;; -esac - -if [ -n "$(git status --porcelain)" ]; then - echo "error: working tree not clean. Commit or stash first." >&2 - git status --short >&2 - exit 1 -fi - -CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) -if [ "$CURRENT_BRANCH" != "main" ]; then - echo "error: release:expo must run from main (currently on $CURRENT_BRANCH)" >&2 - exit 1 -fi - -require_green_ci - -echo "→ running prerelease checks for the expo package (lint, tests, build)..." -bun run lint -bun --filter @reprojs/expo test -bun run expo:build - -# changelogen's getLastGitTag returns the most recent reachable tag regardless -# of prefix — a recently-cut v*.*.* dashboard tag or sdk-v*.*.* tag would -# become the base and the compare range would be wrong. Pin --from to the -# most recent expo-v* tag. -LAST_EXPO_TAG=$(git tag --list 'expo-v*.*.*' --sort=-version:refname | head -n1) -if [ -z "$LAST_EXPO_TAG" ]; then - echo "error: no expo-v*.*.* tag found to use as --from base." >&2 - echo " Bootstrap by cutting expo-v0.0.0 first (see header of this script)." >&2 - exit 1 -fi -echo "→ using $LAST_EXPO_TAG as changelog base" - -# Compute the target version ourselves. changelogen silently downgrades bumps -# on 0.x versions (minor → patch, major → minor) — we want literal intent. -CURRENT_VERSION=$(node -p "require('./packages/expo/package.json').version") -NEW_VERSION=$(node -e ' - const [M, m, pa] = process.argv[1].split(".").map(Number); - const bump = process.argv[2]; - const next = - bump === "major" ? [M + 1, 0, 0] : - bump === "minor" ? [M, m + 1, 0] : - [M, m, pa + 1]; - console.log(next.join(".")); -' "$CURRENT_VERSION" "$BUMP") -TAG="expo-v${NEW_VERSION}" - -echo "→ bumping @reprojs/expo $CURRENT_VERSION → $NEW_VERSION via changelogen..." -( - cd packages/expo - # --no-tag: changelogen hardcodes an unprefixed `git tag v${version}` that - # can collide with the dashboard's existing v*.*.* tags and abort the - # release. amend_release_commit_and_retag creates the real `expo-v*` tag - # below, so the unprefixed one is pure collision risk. - bunx changelogen --release \ - -r "$NEW_VERSION" \ - --from "$LAST_EXPO_TAG" \ - --no-github \ - --no-tag -) - -echo "→ filtering CHANGELOG to commits that touched @reprojs/expo paths..." -filter_changelog_by_paths \ - packages/expo/CHANGELOG.md \ - "$LAST_EXPO_TAG" \ - packages/expo packages/sdk-utils packages/shared packages/recorder -amend_release_commit_and_retag "$TAG" - -echo "" -echo "✓ tagged $TAG locally with scoped CHANGELOG entry." -echo "" -echo "Next: push to trigger publish-expo.yml" -echo " git push --follow-tags" -echo "" -echo "(Push is intentionally manual — npm publishes are immutable within" -echo " 72h, so review the tag before it ships.)" diff --git a/scripts/release-extension.sh b/scripts/release-extension.sh deleted file mode 100755 index 553c58cb..00000000 --- a/scripts/release-extension.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env bash -# Bumps apps/extension's version, runs prerelease checks, writes -# apps/extension/CHANGELOG.md, commits, and tags as extension-vX.Y.Z. The tag -# push triggers .github/workflows/publish-extension.yml, which zips the built -# dist/ and uploads to the Chrome Web Store. -# -# Usage: -# bun run release:extension [patch|minor|major] -# -# Decoupled from the SDK release (sdk-v*) and dashboard release (v*). - -set -euo pipefail - -# shellcheck source=lib/ci-gate.sh -. "$(dirname "$0")/lib/ci-gate.sh" -# shellcheck source=lib/scope-changelog.sh -. "$(dirname "$0")/lib/scope-changelog.sh" - -BUMP="${1:-patch}" -case "$BUMP" in - patch|minor|major) ;; - *) echo "usage: $0 [patch|minor|major]" >&2; exit 2 ;; -esac - -if [ -n "$(git status --porcelain)" ]; then - echo "error: working tree not clean. Commit or stash first." >&2 - git status --short >&2 - exit 1 -fi - -CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) -if [ "$CURRENT_BRANCH" != "main" ]; then - echo "error: release:extension must run from main (currently on $CURRENT_BRANCH)" >&2 - exit 1 -fi - -require_green_ci - -echo "→ running prerelease checks (lint, test:sdk, sdk:build)..." -bun run prerelease - -echo "→ verifying extension unit tests + build..." -bun run ext:test -bun run ext:build - -# Pin --from to the last extension-v* tag so changelogen doesn't pick up an -# unrelated sdk-v* or v* tag as its base. Bootstrap path: if no extension-v* -# tag exists yet, use HEAD~0 (empty compare range) so changelogen writes a -# fresh CHANGELOG section. -LAST_EXT_TAG=$(git tag --list 'extension-v*.*.*' --sort=-version:refname | head -n1) -if [ -z "$LAST_EXT_TAG" ]; then - echo "→ no prior extension-v*.*.* tag; using initial commit as compare base" - LAST_EXT_TAG=$(git rev-list --max-parents=0 HEAD | head -n1) -else - echo "→ using $LAST_EXT_TAG as changelog base" -fi - -CURRENT_VERSION=$(node -p "require('./apps/extension/package.json').version") -NEW_VERSION=$(node -e ' - const [M, m, pa] = process.argv[1].split(".").map(Number); - const bump = process.argv[2]; - const next = - bump === "major" ? [M + 1, 0, 0] : - bump === "minor" ? [M, m + 1, 0] : - [M, m, pa + 1]; - console.log(next.join(".")); -' "$CURRENT_VERSION" "$BUMP") -TAG="extension-v${NEW_VERSION}" - -echo "→ bumping @reprojs/extension $CURRENT_VERSION → $NEW_VERSION via changelogen..." -( - cd apps/extension - # --no-tag: changelogen hardcodes an unprefixed `git tag v${version}` that - # collides with the dashboard's existing v*.*.* tags (e.g. an old v0.1.3) - # and aborts the release. amend_release_commit_and_retag creates the real - # `extension-v*` tag below, so the unprefixed one is pure collision risk. - bunx changelogen --release \ - -r "$NEW_VERSION" \ - --from "$LAST_EXT_TAG" \ - --no-github \ - --no-tag -) - -# The extension bundles @reprojs/core at build time, so changes to core's -# tree are functionally part of an extension release. Keep commits that -# touched the extension itself OR any of the SDK paths the bundle pulls in. -echo "→ filtering CHANGELOG to commits that touched extension or bundled SDK paths..." -filter_changelog_by_paths \ - apps/extension/CHANGELOG.md \ - "$LAST_EXT_TAG" \ - apps/extension packages/core packages/ui packages/sdk-utils packages/shared packages/recorder -amend_release_commit_and_retag "$TAG" - -echo "" -echo "✓ tagged $TAG locally with scoped CHANGELOG entry." -echo "" -echo "Next: push to trigger publish-extension.yml" -echo " git push --follow-tags" -echo "" -echo "(Push is intentionally manual — Chrome Web Store review is public-facing," -echo " so review the tag before it ships.)" diff --git a/scripts/release-sdk.sh b/scripts/release-sdk.sh deleted file mode 100755 index 1b2b4c2b..00000000 --- a/scripts/release-sdk.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env bash -# Bumps packages/core's version, runs prerelease checks, writes -# packages/core/CHANGELOG.md, commits, and tags as sdk-vX.Y.Z. The tag push -# triggers .github/workflows/publish-npm.yml, which republishes @reprojs/core -# from source with provenance and creates the GitHub Release. -# -# Usage: -# bun run release:sdk [patch|minor|major] -# -# The SDK release is intentionally decoupled from the dashboard release -# (which uses `bun run release:minor` and v*.*.* tags), so a dashboard-only -# change doesn't force a churn republish of the SDK. -# -# Why delegate to changelogen instead of bumping package.json in the shell: -# - Generates packages/core/CHANGELOG.md so the npm-side release line has -# its own changelog, independent of the dashboard's root CHANGELOG.md. -# - Produces the release notes that publish-npm.yml pastes into the GH -# Release, so consumers get a real changelog on every tag. - -set -euo pipefail - -# shellcheck source=lib/ci-gate.sh -. "$(dirname "$0")/lib/ci-gate.sh" -# shellcheck source=lib/scope-changelog.sh -. "$(dirname "$0")/lib/scope-changelog.sh" - -BUMP="${1:-patch}" -case "$BUMP" in - patch|minor|major) ;; - *) echo "usage: $0 [patch|minor|major]" >&2; exit 2 ;; -esac - -# Refuse to release from a dirty working tree — changelogen commits whatever -# is staged, and we only want the version bump + changelog update in the -# release commit. -if [ -n "$(git status --porcelain)" ]; then - echo "error: working tree not clean. Commit or stash first." >&2 - git status --short >&2 - exit 1 -fi - -CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) -if [ "$CURRENT_BRANCH" != "main" ]; then - echo "error: release:sdk must run from main (currently on $CURRENT_BRANCH)" >&2 - exit 1 -fi - -require_green_ci - -echo "→ running prerelease checks (lint, test:sdk, sdk:build)..." -bun run prerelease - -# changelogen's getLastGitTag uses `git describe --tags --abbrev=0`, which -# returns the most recent reachable tag regardless of prefix. Without an -# explicit --from, a recently-cut v*.*.* dashboard tag would become the SDK -# changelog's base and the compare range would be wrong. Pin --from to the -# last sdk-v* tag. -LAST_SDK_TAG=$(git tag --list 'sdk-v*.*.*' --sort=-version:refname | head -n1) -if [ -z "$LAST_SDK_TAG" ]; then - echo "error: no sdk-v*.*.* tag found to use as --from base" >&2 - exit 1 -fi -echo "→ using $LAST_SDK_TAG as changelog base" - -# Compute the target version ourselves instead of letting changelogen's -# `--patch/--minor/--major` decide. changelogen silently downgrades bumps -# while on 0.x versions (minor → patch, major → minor), which would change -# this script's existing behavior where `release:sdk minor` means a real -# minor bump. Pass -r so the intent is literal. -CURRENT_VERSION=$(node -p "require('./packages/core/package.json').version") -NEW_VERSION=$(node -e ' - const [M, m, pa] = process.argv[1].split(".").map(Number); - const bump = process.argv[2]; - const next = - bump === "major" ? [M + 1, 0, 0] : - bump === "minor" ? [M, m + 1, 0] : - [M, m, pa + 1]; - console.log(next.join(".")); -' "$CURRENT_VERSION" "$BUMP") -TAG="sdk-v${NEW_VERSION}" - -echo "→ bumping @reprojs/core $CURRENT_VERSION → $NEW_VERSION via changelogen..." -# --no-github: changelogen's GitHub Release sync hardcodes `v${version}` as -# the tag name, which wouldn't match our `sdk-v*` tag. publish-npm.yml -# creates the GH Release itself after npm publish succeeds. -# --no-tag: changelogen also creates a local `git tag v${version}` (hardcoded, -# unprefixed). Once the dashboard's v*.*.* line reaches the SDK's numbering -# (e.g. an old dashboard v0.4.1 already exists), that `git tag v0.4.1` collides -# and aborts the release mid-run. We don't want the unprefixed tag anyway — -# amend_release_commit_and_retag below creates the real `sdk-v*` tag. -( - cd packages/core - bunx changelogen --release \ - -r "$NEW_VERSION" \ - --from "$LAST_SDK_TAG" \ - --no-github \ - --no-tag -) - -# changelogen has no path filter — without this, packages/core/CHANGELOG.md -# inherits every commit since the last sdk-v* tag, including dashboard-only -# and unrelated work. Filter to only commits that touched the paths bundled -# into @reprojs/core. Then amend the release commit + move the tag forward -# so the upstream content matches the file. -echo "→ filtering CHANGELOG to commits that touched @reprojs/core paths..." -filter_changelog_by_paths \ - packages/core/CHANGELOG.md \ - "$LAST_SDK_TAG" \ - packages/core packages/ui packages/sdk-utils packages/shared packages/recorder -amend_release_commit_and_retag "$TAG" - -echo "" -echo "✓ tagged $TAG locally with scoped CHANGELOG entry." -echo "" -echo "Next: push to trigger publish-npm.yml" -echo " git push --follow-tags" -echo "" -echo "(Push is intentionally manual — npm publishes are immutable within" -echo " 72h, so review the tag before it ships.)" diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 00000000..a6eea03b --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,287 @@ +#!/usr/bin/env bash +# One command to release every artifact affected by what's on main. +# +# bun run release # patch every affected artifact +# bun run release --minor # level applies to all affected +# bun run release --dry-run # print the plan, touch nothing +# bun run release --only sdk # restrict to these (comma-separated) +# bun run release --skip extension # inverse of --only +# bun run release --yes # skip the confirm prompt +# +# Replaces release-{sdk,dashboard,extension,expo}.sh, which were four +# near-identical scripts. Releasing one bugfix that reached three artifacts +# meant three commands, three pushes, and three full CI cycles — because each +# release commit moved HEAD, and every script re-gated on green CI for the new +# HEAD. This does the whole set in one commit, one CI gate, one prompt. +# +# Mixed bump levels aren't expressible in a single run by design; compose with +# --only instead: +# bun run release --minor --only sdk +# bun run release --only dashboard,extension +# +# Version lines stay independent per artifact (sdk-v0.4.2 / v0.6.5 / +# extension-v0.1.4), so a dashboard-only change still can't churn a republish +# of @reprojs/core to npm. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +# shellcheck source=lib/ci-gate.sh +. "$(dirname "$0")/lib/ci-gate.sh" +# shellcheck source=lib/scope-changelog.sh +. "$(dirname "$0")/lib/scope-changelog.sh" + +# Everything bundled into the SDK IIFE. The dashboard bakes that bundle into +# its image (Dockerfile) and the extension syncs it at build time +# (apps/extension/scripts/sync-sdk.ts), so a change to any of these has to +# reach all three — which is exactly what got missed when a packages/ui CSP +# fix shipped to npm while the dashboard and extension kept serving the old +# bundle. +SDK_SRC="packages/core packages/ui packages/sdk-utils packages/shared packages/recorder" + +# name | version dir | tag prefix | own paths | bundled-dependency paths +# +# "Affected" = any commit since this artifact's last tag touched (own + +# bundled) paths. Propagation needs no special-casing: it falls out of listing +# what each artifact is actually built from. Note expo deliberately does NOT +# list packages/ui — it ships its own React Native UI and never bundles the +# web widget. +artifact_rows() { + cat <&2; exit 2 ;; + esac + shift +done + +in_csv() { + case ",$2," in *",$1,"*) return 0 ;; *) return 1 ;; esac +} + +pkg_json_for() { + if [ "$1" = "." ]; then echo "package.json"; else echo "$1/package.json"; fi +} + +changelog_for() { + if [ "$1" = "." ]; then echo "CHANGELOG.md"; else echo "$1/CHANGELOG.md"; fi +} + +# Compute the next version ourselves rather than using changelogen's +# --patch/--minor/--major: those silently downgrade while on 0.x (minor → +# patch, major → minor), so `--minor` would quietly not be a minor. Passing an +# explicit -r makes the intent literal. +next_version() { + node -e ' + const [M, m, pa] = process.argv[1].split(".").map(Number); + const bump = process.argv[2]; + const next = + bump === "major" ? [M + 1, 0, 0] : + bump === "minor" ? [M, m + 1, 0] : + [M, m, pa + 1]; + console.log(next.join(".")); + ' "$1" "$2" +} + +if [ "$(git rev-parse --abbrev-ref HEAD)" != "main" ]; then + echo "error: release must run from main (currently on $(git rev-parse --abbrev-ref HEAD))" >&2 + exit 1 +fi + +if [ -n "$(git status --porcelain)" ]; then + echo "error: working tree not clean. Commit or stash first." >&2 + git status --short >&2 + exit 1 +fi + +# --- Work out what's affected ------------------------------------------------ + +PLAN="" # name|dir|tag|new_version|reason +COUNT=0 + +while IFS='|' read -r NAME DIR PREFIX OWN DEPS; do + [ -z "$NAME" ] && continue + if [ -n "$ONLY" ] && ! in_csv "$NAME" "$ONLY"; then continue; fi + if [ -n "$SKIP" ] && in_csv "$NAME" "$SKIP"; then continue; fi + + LAST_TAG=$(git tag --list "${PREFIX}*.*.*" --sort=-version:refname | head -n1) + if [ -z "$LAST_TAG" ]; then + echo "warning: no ${PREFIX}*.*.* tag found — skipping $NAME" >&2 + continue + fi + + # `git log --invert-grep`, not `git diff`. A release commit bumps its own + # package.json + CHANGELOG.md, which live inside the artifact's paths — so a + # plain diff counts "chore(release): sdk-v0.4.2" as an SDK change, marks + # everything that bundles core as affected, and every release makes the next + # one look necessary. Ignoring release commits breaks that loop; they're the + # only thing that writes CHANGELOG.md, so nothing real is filtered out. + # shellcheck disable=SC2086 # word splitting is how the pathspecs are passed + OWN_CHANGED=$(git log --format=%h --invert-grep --grep='^chore(release):' "${LAST_TAG}..HEAD" -- $OWN) + DEP_CHANGED="" + if [ -n "$DEPS" ]; then + # shellcheck disable=SC2086 + DEP_CHANGED=$(git log --format=%h --invert-grep --grep='^chore(release):' "${LAST_TAG}..HEAD" -- $DEPS) + fi + [ -z "$OWN_CHANGED$DEP_CHANGED" ] && continue + + if [ -n "$OWN_CHANGED" ]; then REASON="own changes"; else REASON="bundles @reprojs/core"; fi + + CURRENT=$(node -p "require('./$(pkg_json_for "$DIR")').version") + NEW=$(next_version "$CURRENT" "$BUMP") + PLAN="${PLAN}${NAME}|${DIR}|${PREFIX}${NEW}|${NEW}|${CURRENT}|${REASON}|${LAST_TAG}|${OWN} ${DEPS} +" + COUNT=$((COUNT + 1)) +done </dev/null + ) + # changelogen has no path filter, so every artifact's CHANGELOG would + # otherwise inherit every commit in the range, dashboard-only work included. + # shellcheck disable=SC2086 + filter_changelog_by_paths "$(changelog_for "$DIR")" "$LAST_TAG" $PATHS + git add "$(pkg_json_for "$DIR")" "$(changelog_for "$DIR")" + TAGS="$TAGS $TAG" +done < Date: Fri, 17 Jul 2026 12:33:54 +0800 Subject: [PATCH 2/2] fix(release): stop the changelog filter deleting released history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filter_changelog_by_paths ran over the WHOLE CHANGELOG.md, dropping every line whose [sha] wasn't in the keep-set. The keep-set only covers FROM..HEAD, so every previously-released entry's bullets referenced SHAs outside the range and were deleted — each release eroding a bit more of the file. packages/core/CHANGELOG.md, entry by entry, as of sdk-v0.4.0 vs now: sdk-v0.4.0 257 bullets → 2 sdk-v0.3.0 107 bullets → 1 sdk-v0.2.1 4 bullets → 1 The headings survived (no [sha] to match), so the file kept looking plausible while its contents drained away. That gutted file is what ships in the npm tarball and gets pasted into each GitHub Release. Only filter the section changelogen just prepended; copy everything from the second `## ` heading down through untouched. `## ` matches version headings only — `### Fixes` has no space in the third column. This is pre-existing and independent of the release-script unification; it just took releasing from a clone and diffing the result to notice. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/lib/scope-changelog.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scripts/lib/scope-changelog.sh b/scripts/lib/scope-changelog.sh index 49ff995e..9f0c5371 100755 --- a/scripts/lib/scope-changelog.sh +++ b/scripts/lib/scope-changelog.sh @@ -41,12 +41,28 @@ filter_changelog_by_paths() { # awk filter: each line either has a [shorthash] commit ref OR doesn't. # If it has one, keep the line iff the SHA is in our kept set. Lines # without a ref (headers, blanks, "compare changes" links) pass through. + # + # Crucially this only applies to the section changelogen just prepended — + # everything from the SECOND `## ` heading down is previously-released + # history and is copied through untouched. + # + # It used to filter the whole file. The keep-set only covers FROM..HEAD, so + # every past release's bullets referenced SHAs outside the range and were + # silently deleted — each release eroding the changelog a bit more. + # packages/core/CHANGELOG.md lost its sdk-v0.4.0 entry down from 257 bullets + # to 2 that way, and that gutted file is what ships to npm and gets pasted + # into the GitHub Release. + # + # `## ` matches version headings only: `### Fixes` has no space in the third + # column, so subsection headings don't increment the counter. awk -v sha_file="$sha_file" ' BEGIN { while ((getline line < sha_file) > 0) keep[line] = 1 close(sha_file) } + /^## / { sections++ } { + if (sections >= 2) { print; next } if (match($0, /\[[a-f0-9]{7,12}\]/) > 0) { sha = substr($0, RSTART + 1, RLENGTH - 2) if (!(sha in keep)) next