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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/dev-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <semver>` (merge the PR), then run `scripts/release.sh [--tag <dist-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

Expand Down
3 changes: 3 additions & 0 deletions scripts/npm-trust-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -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',
Expand Down
11 changes: 10 additions & 1 deletion scripts/publish-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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' });
Comment thread
krassx marked this conversation as resolved.
Resolved
else process.stdout.write(run('npm', args, { stdio: ['ignore', 'pipe', 'pipe'] }));
done++;
} catch (error) {
const text = `${error.stdout ?? ''}${error.stderr ?? ''}`;
Expand All @@ -111,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;
}
Expand Down
181 changes: 181 additions & 0 deletions scripts/release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#!/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,17p' "$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."
}

# 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
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"
Comment thread
krassx marked this conversation as resolved.
Resolved
run=0
for _ in $(seq 1 30); do
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" -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 2>&1 | tail -30 || true
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"

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 && [ "$(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, 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 [ "${#todo[@]}" = 0 ]; then
echo "none — every package already exists and is trusted, so trusted publishing covers the whole release."
else
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"
printf '%s\n' "${todo[@]}" > "$pending_file"

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 "${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 (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"
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"
# 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"
Loading