From 5f937da3e7ccf2ec0f771b970213b66ea2115af4 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 30 Oct 2026 11:55:22 +0500 Subject: [PATCH 1/4] chore(release): scripts/release.sh automates bootstrap, workflow publish, verify and tag Co-Authored-By: Claude Sonnet 5.5 --- docs/dev-environment.md | 2 +- scripts/release.sh | 159 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100755 scripts/release.sh diff --git a/docs/dev-environment.md b/docs/dev-environment.md index 8f407142..0610f163 100644 --- a/docs/dev-environment.md +++ b/docs/dev-environment.md @@ -73,7 +73,7 @@ Methodology (test-first TDD + per-entity **mutator loop** + integration tests at ## Versioning & release -Changesets, independent versioning. `@bugsee/protocol` exact-pinned by every consumer (protocol drift is dangerous); others use caret ranges. Release: `pnpm changeset` → `pnpm version-packages` → `pnpm release` (CI publishes from `main` after tag). CDN bundles publish **SRI hashes** per release; Rollup build fails if `eval`/`Function()`/`setTimeout(string)` appears (§12.6/§12.9). +Changesets, independent versioning. `@bugsee/protocol` exact-pinned by every consumer (protocol drift is dangerous); others use caret ranges. Release: `pnpm changeset` → `pnpm version-packages` → `pnpm release` (CI publishes from `main` after tag). **Releasing the version on `main`:** bump with `node scripts/bump-version.mjs ` (merge the PR), then run `scripts/release.sh [--tag ]` from a clean `main`. It checks CI, first-publishes any package that was never on npm (trusted publishing cannot do a first publish, so that step needs your npm auth/2FA), runs the Release workflow (dry run, then real), verifies every package on the registry, and tags + creates the GitHub release. It is resumable. CDN bundles publish **SRI hashes** per release; Rollup build fails if `eval`/`Function()`/`setTimeout(string)` appears (§12.6/§12.9). ## Open / unconfirmed tooling decisions diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 00000000..e153dead --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# Release the version already on main (set it first with scripts/bump-version.mjs + a merged PR). +# +# scripts/release.sh # preflight → bootstrap new packages → Release workflow → verify → tag +# scripts/release.sh --tag beta # npm dist-tag to publish under (default: latest) +# scripts/release.sh --yes # no confirmation prompts +# scripts/release.sh --skip-github-release # stop after npm verification: no git tag, no GitHub release +# +# What it does, in order: +# 1. Preflight: on main == origin/main, clean tree, CI green on HEAD, gh + npm logged in, gate passes. +# 2. Bootstrap: a package that has NEVER been on npm cannot use trusted publishing (OIDC) for its first +# publish, so it is published from this machine and then has the trusted publisher attached. This is +# the only step that needs your npm auth / 2FA; it is skipped when every package already exists. +# 3. Dispatch the Release workflow: dry run, then the real publish (it skips versions already on npm, +# so re-running after a partial failure resumes). +# 4. Verify every package is readable at the version, with the expected dist-tag. +# 5. Tag vX.Y.Z and create the GitHub release (pre-release when the version has a `-` suffix). +set -euo pipefail +cd "$(dirname "$0")/.." + +TAG=latest +ASSUME_YES=0 +GH_RELEASE=1 +while [ $# -gt 0 ]; do + case "$1" in + --tag) TAG="$2"; shift ;; + --yes) ASSUME_YES=1 ;; + --skip-github-release) GH_RELEASE=0 ;; + -h|--help) sed -n '2,19p' "$0"; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac + shift +done + +step() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } +die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; } +confirm() { + [ "$ASSUME_YES" = 1 ] && return 0 + printf '%s [y/N] ' "$1" + read -r answer + [ "$answer" = "y" ] || [ "$answer" = "Y" ] || die "aborted." +} + +# Wait for a workflow run started by `gh workflow run` (id looked up after a short settle). +dispatch_and_wait() { + local dry="$1" before run + before=$(gh run list --workflow release.yml --limit 1 --json databaseId --jq '.[0].databaseId // 0') + gh workflow run release.yml --ref main -f tag="$TAG" -f dry_run="$dry" + for _ in $(seq 1 30); do + run=$(gh run list --workflow release.yml --limit 1 --json databaseId --jq '.[0].databaseId // 0') + [ "$run" != "$before" ] && break + sleep 2 + done + [ "$run" != "$before" ] || die "the dispatched Release run never appeared" + echo "run $run: $(gh run view "$run" --json url --jq .url)" + gh run watch "$run" --interval 15 --exit-status >/dev/null || { + gh run view "$run" --log-failed | tail -30 + die "Release workflow run $run failed (dry_run=$dry)" + } +} + +step "Preflight" +branch=$(git rev-parse --abbrev-ref HEAD) +[ "$branch" = "main" ] || die "on branch '$branch'; release from main" +[ -z "$(git status --porcelain)" ] || die "working tree is not clean (commit or stash first)" +git fetch -q --tags origin main +[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] || die "HEAD is not origin/main (pull first)" +sha=$(git rev-parse HEAD) +echo "main @ ${sha:0:8}" + +command -v gh >/dev/null || die "gh CLI not found" +gh auth status >/dev/null 2>&1 || die "gh is not logged in — run: gh auth login" +user=$(npm whoami 2>/dev/null) || die "not logged in to npm — run: npm login" +echo "npm user: $user" + +pnpm check:publishable +version=$(node -p "require('./packages/core/package.json').version") +tagname="v$version" +if git rev-parse -q --verify "refs/tags/$tagname" >/dev/null; then + die "tag $tagname already exists — bump the version first (node scripts/bump-version.mjs)" +fi + +ci=$(gh run list --branch main --workflow ci.yml --commit "$sha" --limit 1 --json conclusion,status --jq '.[0]|"\(.status)/\(.conclusion)"' 2>/dev/null || true) +[ "$ci" = "completed/success" ] || die "CI on ${sha:0:8} is '${ci:-none}', expected completed/success" +echo "CI green on HEAD; releasing $version under dist-tag '$TAG'" + +step "Packages that have never been on npm" +new=() +while read -r name; do + npm view "$name" version >/dev/null 2>&1 || new+=("$name") +done < <(node scripts/check-publishable.mjs --list) + +if [ "${#new[@]}" = 0 ]; then + echo "none — every package already exists, so trusted publishing covers the whole release." +else + echo "needs a first publish from this machine: ${new[*]}" + npm trust github --help 2>/dev/null | grep -q allow-publish \ + || die "this npm ($(npm --version)) cannot set the trust permission the registry requires — run: npm install -g npm@latest" + confirm "Publish ${#new[@]} new package(s) as $user (you will be asked for 2FA)?" + + pnpm install --frozen-lockfile + pnpm exec turbo run build + only=$(IFS=,; echo "${new[*]}") + node scripts/publish-all.mjs --only "$only" --tag "$TAG" + + # A publish is accepted (202) and only readable a little later; trust can attach only once it exists. + deadline=$((SECONDS + 900)) + for name in "${new[@]}"; do + until [ "$(npm view "$name@$version" version --prefer-online 2>/dev/null || true)" = "$version" ]; do + [ "$SECONDS" -lt "$deadline" ] || die "$name@$version still not readable after 15 min — re-run to resume" + echo "waiting for the registry: $name" + sleep 20 + done + done + + step "Attach the trusted publisher (skips packages already trusted; asks for 2FA if needed)" + node scripts/npm-trust-all.mjs +fi + +step "Release workflow: dry run" +dispatch_and_wait true +confirm "Dry run passed. Publish $version to npm under '$TAG' for real?" + +step "Release workflow: publish" +dispatch_and_wait false + +step "Verify on the registry" +deadline=$((SECONDS + 900)) +while :; do + missing=() + while read -r name; do + got=$(npm view "$name@$version" version --prefer-online 2>/dev/null || true) + [ "$got" = "$version" ] || missing+=("$name") + done < <(node scripts/check-publishable.mjs --list) + [ "${#missing[@]}" = 0 ] && break + [ "$SECONDS" -lt "$deadline" ] || die "not readable after 15 min: ${missing[*]} — re-run to resume" + echo "waiting for the registry: ${#missing[@]} package(s) not readable yet" + sleep 30 +done +count=$(node scripts/check-publishable.mjs --list | wc -l | tr -d ' ') +wrong=$(npm view @bugsee/bugsee "dist-tags.$TAG" --prefer-online 2>/dev/null || true) +[ "$wrong" = "$version" ] || die "dist-tag '$TAG' on @bugsee/bugsee is '$wrong', expected $version" +echo "all $count packages readable at $version; dist-tag $TAG -> $version" + +if [ "$GH_RELEASE" = 0 ]; then + echo "skipping tag and GitHub release (--skip-github-release)." + exit 0 +fi + +step "Tag $tagname and create the GitHub release" +prev=$(git tag --list 'v*' --sort=-creatordate | head -1 || true) +git tag -a "$tagname" -m "$tagname" "$sha" +git push origin "$tagname" +flags=(--title "$version" --verify-tag) +case "$version" in *-*) flags+=(--prerelease) ;; esac +if [ -n "$prev" ]; then flags+=(--generate-notes --notes-start-tag "$prev"); else flags+=(--generate-notes); fi +gh release create "$tagname" "${flags[@]}" + +step "Done: $version is released" From b57aaea735dc52dd8cfa7e73abb9da21b7306145 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 30 Oct 2026 12:04:19 +0500 Subject: [PATCH 2/4] fix(release): interactive bootstrap publish, resumable trust and tag, pinned dispatch commit Co-Authored-By: Claude Sonnet 5.5 --- scripts/npm-trust-all.mjs | 3 ++ scripts/publish-all.mjs | 5 ++- scripts/release.sh | 84 ++++++++++++++++++++++++--------------- 3 files changed, 60 insertions(+), 32 deletions(-) diff --git a/scripts/npm-trust-all.mjs b/scripts/npm-trust-all.mjs index 4bde747f..d1839b3c 100644 --- a/scripts/npm-trust-all.mjs +++ b/scripts/npm-trust-all.mjs @@ -16,6 +16,8 @@ const REPO = 'bugsee/bugsee-javascript'; const WORKFLOW = 'release.yml'; const argv = process.argv.slice(2); const dryRun = argv.includes('--dry-run'); +const onlyIndex = argv.indexOf('--only'); +const only = onlyIndex === -1 ? undefined : argv[onlyIndex + 1].split(','); const otpIndex = argv.indexOf('--otp'); let otp = otpIndex === -1 ? undefined : argv[otpIndex + 1]; @@ -65,6 +67,7 @@ const failures = []; packages: for (const { json } of loadPackages()) { const name = json.name; + if (only && !only.includes(name)) continue; const args = [ 'trust', 'github', diff --git a/scripts/publish-all.mjs b/scripts/publish-all.mjs index 517dbeba..90c09d3c 100644 --- a/scripts/publish-all.mjs +++ b/scripts/publish-all.mjs @@ -34,6 +34,8 @@ const value = (name) => { const dryRun = flag('--dry-run'); const tag = value('--tag') ?? 'latest'; const only = value('--only')?.split(','); +// Hand the terminal to npm so it can prompt for a 2FA code / show a web-auth URL (a human is at the keyboard). +const interactive = flag('--interactive'); const run = (cmd, args, options = {}) => execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], ...options }); @@ -100,7 +102,8 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { if (dryRun) args.push('--dry-run'); console.log(`${dryRun ? 'dry-run ' : 'publish '} ${name}@${version} (tag ${tag})`); try { - process.stdout.write(run('npm', args, { stdio: ['ignore', 'pipe', 'pipe'] })); + if (interactive) run('npm', args, { stdio: 'inherit' }); + else process.stdout.write(run('npm', args, { stdio: ['ignore', 'pipe', 'pipe'] })); done++; } catch (error) { const text = `${error.stdout ?? ''}${error.stderr ?? ''}`; diff --git a/scripts/release.sh b/scripts/release.sh index e153dead..4389a193 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -26,7 +26,7 @@ while [ $# -gt 0 ]; do --tag) TAG="$2"; shift ;; --yes) ASSUME_YES=1 ;; --skip-github-release) GH_RELEASE=0 ;; - -h|--help) sed -n '2,19p' "$0"; exit 0 ;; + -h|--help) sed -n '2,17p' "$0"; exit 0 ;; *) echo "unknown option: $1" >&2; exit 2 ;; esac shift @@ -41,17 +41,22 @@ confirm() { [ "$answer" = "y" ] || [ "$answer" = "Y" ] || die "aborted." } -# Wait for a workflow run started by `gh workflow run` (id looked up after a short settle). +# Dispatch the Release workflow and watch THAT run. main must still be the commit preflight checked (a merge +# during a confirm prompt would otherwise publish a different commit than the one we tag), and the run is found by +# event + commit, newer than the last one seen, so another person's run is never watched. dispatch_and_wait() { local dry="$1" before run - before=$(gh run list --workflow release.yml --limit 1 --json databaseId --jq '.[0].databaseId // 0') + git fetch -q origin main + [ "$(git rev-parse origin/main)" = "$sha" ] || die "origin/main moved since preflight — pull and re-run" + before=$(gh run list --workflow release.yml --event workflow_dispatch --limit 1 --json databaseId --jq '.[0].databaseId // 0') gh workflow run release.yml --ref main -f tag="$TAG" -f dry_run="$dry" + run=0 for _ in $(seq 1 30); do - run=$(gh run list --workflow release.yml --limit 1 --json databaseId --jq '.[0].databaseId // 0') - [ "$run" != "$before" ] && break + run=$(gh run list --workflow release.yml --event workflow_dispatch --commit "$sha" --limit 1 --json databaseId --jq '.[0].databaseId // 0') + [ "$run" -gt "$before" ] && break sleep 2 done - [ "$run" != "$before" ] || die "the dispatched Release run never appeared" + [ "$run" -gt "$before" ] || die "the dispatched Release run never appeared" echo "run $run: $(gh run view "$run" --json url --jq .url)" gh run watch "$run" --interval 15 --exit-status >/dev/null || { gh run view "$run" --log-failed | tail -30 @@ -70,51 +75,63 @@ echo "main @ ${sha:0:8}" command -v gh >/dev/null || die "gh CLI not found" gh auth status >/dev/null 2>&1 || die "gh is not logged in — run: gh auth login" -user=$(npm whoami 2>/dev/null) || die "not logged in to npm — run: npm login" -echo "npm user: $user" pnpm check:publishable version=$(node -p "require('./packages/core/package.json').version") tagname="v$version" -if git rev-parse -q --verify "refs/tags/$tagname" >/dev/null; then - die "tag $tagname already exists — bump the version first (node scripts/bump-version.mjs)" +if git rev-parse -q --verify "refs/tags/$tagname" >/dev/null && [ "$(git rev-parse "$tagname^{commit}")" != "$sha" ]; then + die "tag $tagname exists on another commit — bump the version first (node scripts/bump-version.mjs)" fi ci=$(gh run list --branch main --workflow ci.yml --commit "$sha" --limit 1 --json conclusion,status --jq '.[0]|"\(.status)/\(.conclusion)"' 2>/dev/null || true) [ "$ci" = "completed/success" ] || die "CI on ${sha:0:8} is '${ci:-none}', expected completed/success" echo "CI green on HEAD; releasing $version under dist-tag '$TAG'" -step "Packages that have never been on npm" +step "Packages that have never been on npm, or published but not yet trusted" +# A first publish cannot use OIDC; trust can attach only once the package exists. Names stay in a marker file +# until the trust step succeeds, so a re-run after a failed trust still finishes it. +pending_file="$(git rev-parse --git-dir)/release-trust-pending" new=() while read -r name; do npm view "$name" version >/dev/null 2>&1 || new+=("$name") done < <(node scripts/check-publishable.mjs --list) +todo=(${new[@]+"${new[@]}"}) +if [ -f "$pending_file" ]; then + while read -r name; do + [ -n "$name" ] && [[ " ${todo[*]:-} " != *" $name "* ]] && todo+=("$name") + done < "$pending_file" +fi -if [ "${#new[@]}" = 0 ]; then - echo "none — every package already exists, so trusted publishing covers the whole release." +if [ "${#todo[@]}" = 0 ]; then + echo "none — every package already exists and is trusted, so trusted publishing covers the whole release." else - echo "needs a first publish from this machine: ${new[*]}" + echo "needs the bootstrap from this machine: ${todo[*]}" + user=$(npm whoami 2>/dev/null) || die "not logged in to npm — run: npm login" + echo "npm user: $user" npm trust github --help 2>/dev/null | grep -q allow-publish \ || die "this npm ($(npm --version)) cannot set the trust permission the registry requires — run: npm install -g npm@latest" - confirm "Publish ${#new[@]} new package(s) as $user (you will be asked for 2FA)?" + printf '%s\n' "${todo[@]}" > "$pending_file" - pnpm install --frozen-lockfile - pnpm exec turbo run build - only=$(IFS=,; echo "${new[*]}") - node scripts/publish-all.mjs --only "$only" --tag "$TAG" + if [ "${#new[@]}" -gt 0 ]; then + confirm "Publish ${#new[@]} new package(s) as $user (npm will prompt for 2FA in this terminal)?" + pnpm install --frozen-lockfile + pnpm exec turbo run build + node scripts/publish-all.mjs --only "$(IFS=,; echo "${new[*]}")" --tag "$TAG" --interactive + fi # A publish is accepted (202) and only readable a little later; trust can attach only once it exists. deadline=$((SECONDS + 900)) - for name in "${new[@]}"; do - until [ "$(npm view "$name@$version" version --prefer-online 2>/dev/null || true)" = "$version" ]; do - [ "$SECONDS" -lt "$deadline" ] || die "$name@$version still not readable after 15 min — re-run to resume" + for name in "${todo[@]}"; do + until npm view "$name" version --prefer-online >/dev/null 2>&1; do + [ "$SECONDS" -lt "$deadline" ] || die "$name still not readable after 15 min — re-run to resume" echo "waiting for the registry: $name" sleep 20 done done - step "Attach the trusted publisher (skips packages already trusted; asks for 2FA if needed)" - node scripts/npm-trust-all.mjs + step "Attach the trusted publisher (asks for 2FA if needed)" + node scripts/npm-trust-all.mjs --only "$(IFS=,; echo "${todo[*]}")" + rm -f "$pending_file" fi step "Release workflow: dry run" @@ -148,12 +165,17 @@ if [ "$GH_RELEASE" = 0 ]; then fi step "Tag $tagname and create the GitHub release" -prev=$(git tag --list 'v*' --sort=-creatordate | head -1 || true) -git tag -a "$tagname" -m "$tagname" "$sha" -git push origin "$tagname" -flags=(--title "$version" --verify-tag) -case "$version" in *-*) flags+=(--prerelease) ;; esac -if [ -n "$prev" ]; then flags+=(--generate-notes --notes-start-tag "$prev"); else flags+=(--generate-notes); fi -gh release create "$tagname" "${flags[@]}" +# Each part is skipped when already done, so a failure here is fixed by re-running. +prev=$(git tag --list 'v*' --sort=-creatordate | grep -vx "$tagname" | head -1 || true) +git rev-parse -q --verify "refs/tags/$tagname" >/dev/null || git tag -a "$tagname" -m "$tagname" "$sha" +git ls-remote --exit-code --tags origin "refs/tags/$tagname" >/dev/null 2>&1 || git push origin "$tagname" +if gh release view "$tagname" >/dev/null 2>&1; then + echo "GitHub release $tagname already exists." +else + flags=(--title "$version" --verify-tag --generate-notes) + case "$version" in *-*) flags+=(--prerelease) ;; esac + [ -z "$prev" ] || flags+=(--notes-start-tag "$prev") + gh release create "$tagname" "${flags[@]}" +fi step "Done: $version is released" From b63a55e1eb137c118904498002ddb92af1f38fb8 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 30 Oct 2026 12:12:57 +0500 Subject: [PATCH 3/4] fix(release): say why the interactive publish cannot skip an accepted-but-unreadable package Co-Authored-By: Claude Sonnet 5.5 --- scripts/publish-all.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/publish-all.mjs b/scripts/publish-all.mjs index 90c09d3c..3be1f87d 100644 --- a/scripts/publish-all.mjs +++ b/scripts/publish-all.mjs @@ -114,6 +114,12 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { skipped++; continue; } + if (interactive) + console.error( + `\n${name}: publish failed. npm's output is on the terminal above (not captured in --interactive mode, so the\n` + + '"accepted earlier, not readable yet" skip cannot apply). If it said "cannot publish over the previously\n' + + 'published version", the registry already has it: wait a few minutes and re-run.', + ); process.stderr.write(text); throw error; } From 5fecae6cc40f00ffbc875d181d07d9f790d0c4ac Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 30 Oct 2026 12:21:16 +0500 Subject: [PATCH 4/4] fix(release): best-effort failed-log dump so the phase message always prints Co-Authored-By: Claude Sonnet 5.5 --- scripts/release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release.sh b/scripts/release.sh index 4389a193..d98ccb63 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -59,7 +59,7 @@ dispatch_and_wait() { [ "$run" -gt "$before" ] || die "the dispatched Release run never appeared" echo "run $run: $(gh run view "$run" --json url --jq .url)" gh run watch "$run" --interval 15 --exit-status >/dev/null || { - gh run view "$run" --log-failed | tail -30 + gh run view "$run" --log-failed 2>&1 | tail -30 || true die "Release workflow run $run failed (dry_run=$dry)" } }