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..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 @@ -57,16 +73,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 <