#!/bin/bash set -e # Exit on error # Brainy Release Script # Simple, reliable release workflow: build → test → commit → push → publish → release # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Parse arguments RELEASE_TYPE="${1:-patch}" # patch, minor, or major SKIP_TESTS=false DRY_RUN=false # --source-only is now a no-op: The Source is the one registry, so every # release already ships Source-only — tag, CI's publish to The Source, the # release page, and the docs push, with no separate storefront leg to skip. # The flag is still accepted (for backward-compatible invocations) and just # prints a notice; it no longer changes behavior. SOURCE_ONLY=false for arg in "$@"; do case $arg in --skip-tests) SKIP_TESTS=true ;; --dry-run) DRY_RUN=true ;; --source-only) SOURCE_ONLY=true ;; esac done # An explicit version (e.g. "8.0.0-rc.1") may be passed in place of patch/minor/major. EXPLICIT_VERSION="" if [[ "$RELEASE_TYPE" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then EXPLICIT_VERSION="$RELEASE_TYPE" fi # Release on whatever branch we're on — RC lines live on feature branches, not main. CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" echo -e "${BLUE}🚀 Brainy Release Script${NC}" echo -e "${BLUE}Release type: ${RELEASE_TYPE}${NC}" echo -e "${BLUE}Branch: ${CURRENT_BRANCH}${NC}\n" if [ "$DRY_RUN" = true ]; then echo -e "${YELLOW}⚠️ DRY RUN MODE - No changes will be made${NC}\n" fi if [ "$SKIP_TESTS" = true ]; then echo -e "${YELLOW}⚠️ SKIPPING TESTS - Use with caution!${NC}\n" fi # Step 1: Verify clean git state echo -e "${BLUE}1️⃣ Checking git status...${NC}" if [ -n "$(git status --porcelain)" ]; then echo -e "${RED}❌ Working directory not clean. Commit or stash changes first.${NC}" exit 1 fi echo -e "${GREEN}✅ Working directory clean${NC}\n" # Step 2: Build echo -e "${BLUE}2️⃣ Building project...${NC}" if [ "$DRY_RUN" = false ]; then npm run build fi echo -e "${GREEN}✅ Build successful${NC}\n" # Step 3: Test if [ "$SKIP_TESTS" = false ]; then echo -e "${BLUE}3️⃣ Running tests...${NC}" if [ "$DRY_RUN" = false ]; then # Full CI gate: unit + integration (test:ci = test:ci-unit && test:ci-integration). npm run test:ci fi echo -e "${GREEN}✅ Tests passed${NC}\n" else echo -e "${YELLOW}3️⃣ Skipping tests...${NC}\n" fi # Step 4: Get current and new version CURRENT_VERSION=$(node -p "require('./package.json').version") echo -e "${BLUE}Current version: ${CURRENT_VERSION}${NC}" # Calculate new version IFS='.' read -r -a VERSION_PARTS <<< "$CURRENT_VERSION" MAJOR="${VERSION_PARTS[0]}" MINOR="${VERSION_PARTS[1]}" PATCH="${VERSION_PARTS[2]}" if [ -n "$EXPLICIT_VERSION" ]; then NEW_VERSION="$EXPLICIT_VERSION" else case $RELEASE_TYPE in major) NEW_VERSION="$((MAJOR + 1)).0.0" ;; minor) NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" ;; patch) NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" ;; *) echo -e "${RED}❌ Invalid release type: ${RELEASE_TYPE}${NC}" echo "Usage: ./scripts/release.sh [patch|minor|major|] [--dry-run] [--source-only (no-op; The Source is the one registry)]" exit 1 ;; esac fi # A version with a hyphen (e.g. 8.0.0-rc.1) is a prerelease: publish under the 'rc' # dist-tag (NOT 'latest') and mark the GitHub release as a prerelease. PRERELEASE=false NPM_TAG="latest" if [[ "$NEW_VERSION" == *"-"* ]]; then PRERELEASE=true NPM_TAG="rc" fi echo -e "${BLUE}New version: ${NEW_VERSION}${NC}" if [ "$PRERELEASE" = true ]; then echo -e "${YELLOW}⚠️ Prerelease → npm dist-tag '${NPM_TAG}', GitHub prerelease${NC}" fi if [ "$SOURCE_ONLY" = true ]; then echo -e "${YELLOW}⚠️ The Source is the one registry; --source-only is implied${NC}" fi echo "" if [ "$DRY_RUN" = true ]; then echo -e "${YELLOW}DRY RUN: Would release version ${NEW_VERSION}${NC}" exit 0 fi # Step 5: Bump version in package files echo -e "${BLUE}4️⃣ Bumping version to ${NEW_VERSION}...${NC}" npm version $NEW_VERSION --no-git-tag-version echo -e "${GREEN}✅ Version bumped${NC}\n" # Step 6: Update CHANGELOG echo -e "${BLUE}5️⃣ Updating CHANGELOG...${NC}" # Get commits since last tag LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") if [ -z "$LAST_TAG" ]; then COMMITS=$(git log --oneline --pretty=format:"- %s (%h)") else COMMITS=$(git log ${LAST_TAG}..HEAD --oneline --pretty=format:"- %s (%h)") fi # Create new changelog entry CHANGELOG_ENTRY="### [${NEW_VERSION}](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v${CURRENT_VERSION}...v${NEW_VERSION}) ($(date +%Y-%m-%d)) ${COMMITS} " # Prepend to CHANGELOG.md after header if [ -f "CHANGELOG.md" ]; then # Read header (first 4 lines) HEADER=$(head -n 4 CHANGELOG.md) # Read rest of file REST=$(tail -n +5 CHANGELOG.md) # Write new CHANGELOG echo "$HEADER" > CHANGELOG.md echo "" >> CHANGELOG.md echo "$CHANGELOG_ENTRY" >> CHANGELOG.md echo "" >> CHANGELOG.md echo "$REST" >> CHANGELOG.md fi echo -e "${GREEN}✅ CHANGELOG updated${NC}\n" # Step 7: Create release commit echo -e "${BLUE}6️⃣ Creating release commit...${NC}" git add package.json package-lock.json CHANGELOG.md git commit -m "chore(release): ${NEW_VERSION}" echo -e "${GREEN}✅ Release commit created${NC}\n" # Step 8: Create git tag # Annotated (-a) so `git push --follow-tags` below actually pushes it. A lightweight tag is # skipped by --follow-tags, which leaves the tag local-only and makes `gh release create` fail. echo -e "${BLUE}7️⃣ Creating git tag v${NEW_VERSION}...${NC}" git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}" echo -e "${GREEN}✅ Tag created${NC}\n" # Step 9: Push to origin — The Source is the one home (ruled 2026-07-23; the # old public GitHub repo is archived history, no longer part of any release). # TAG FIRST, branch second — deliberately two pushes: the runner is # sequential, and a combined push can queue the release commit's ci.yml run # AHEAD of the tag's publish-source run (observed on 10.0.0: the publish sat # ~37 minutes behind a redundant CI run of the very commit the local gates # had just proven). Pushing the tag alone queues the publish immediately; # the branch push (and its ci.yml run) follows behind it, harmlessly. echo -e "${BLUE}8️⃣ Pushing to origin (tag first — the publish must never queue behind CI)...${NC}" git push origin "v${NEW_VERSION}" git push origin "$CURRENT_BRANCH" echo -e "${GREEN}✅ Pushed to origin${NC}\n" # Step 10: The home publish (The Source, source.soulcraft.com) is CI's job # now, not the laptop's — a tag push (just above) triggers # .forgejo/workflows/publish-source.yml, which builds and publishes on The # Source's own runner (datacenter-side: seconds, not the laptop's WAN timing # out on an 87MB tarball PUT). The laptop holds no home-registry publish # credential anymore; it only waits for CI's result before continuing on to # the release page and the docs push. SOURCE_NPM_REG="https://source.soulcraft.com/api/packages/soulcraftlabs/npm/" SOURCE_POLL_INTERVAL_S=15 SOURCE_POLL_MAX_ATTEMPTS=200 # 200 × 15s = 50 minutes — the runner is sequential and a busy day's ci.yml # backlog has twice exceeded the old 20-minute window (8.10.3, 9.0.0); # ci.yml no longer runs on tag pushes, but same-day branch pushes still queue ahead echo -e "${BLUE}9️⃣ Waiting for CI to publish v${NEW_VERSION} to The Source registry (home)...${NC}" SOURCE_LANDED=false for ((attempt = 1; attempt <= SOURCE_POLL_MAX_ATTEMPTS; attempt++)); do LANDED_VERSION=$(npm view "@soulcraftlabs/brainy@${NEW_VERSION}" version "--@soulcraftlabs:registry=${SOURCE_NPM_REG}" 2>/dev/null || echo "") if [ "$LANDED_VERSION" = "$NEW_VERSION" ]; then SOURCE_LANDED=true break fi echo -e "${YELLOW} … not yet on The Source (attempt ${attempt}/${SOURCE_POLL_MAX_ATTEMPTS}); retrying in ${SOURCE_POLL_INTERVAL_S}s${NC}" sleep "$SOURCE_POLL_INTERVAL_S" done if [ "$SOURCE_LANDED" = true ]; then echo -e "${GREEN}✅ CI published v${NEW_VERSION} to The Source${NC}\n" else echo -e "${RED}❌ CI's home publish did not land — check the workflow run on The Source; the pair must not diverge.${NC}" echo -e "${RED} v${NEW_VERSION} was tagged and pushed, but @soulcraftlabs/brainy@${NEW_VERSION} never became visible on the${NC}" echo -e "${RED} Source registry after ${SOURCE_POLL_MAX_ATTEMPTS} attempts, ${SOURCE_POLL_INTERVAL_S}s apart. Aborting.${NC}" exit 1 fi # Step 11: Release object on The Source (presentational — the tag, CHANGELOG, # and RELEASES.md are the record; this just gives The Source's UI a release page). echo -e "${BLUE}🔟 Creating release page on The Source...${NC}" if [ -n "${FORGEJO_RELEASE_TOKEN:-}" ]; then if curl -sf -X POST "https://source.soulcraft.com/api/v1/repos/soulcraftlabs/open-brainy/releases" \ -H "Authorization: token ${FORGEJO_RELEASE_TOKEN}" -H "Content-Type: application/json" \ -d "{\"tag_name\":\"v${NEW_VERSION}\",\"name\":\"v${NEW_VERSION}\",\"prerelease\":${PRERELEASE}}" >/dev/null; then echo -e "${GREEN}✅ Release page created on The Source${NC}\n" else echo -e "${RED}⚠️ Release-page API call failed — tag + CHANGELOG remain the record; create the page via The Source's UI if wanted${NC}\n" fi else echo -e "${RED}⚠️ FORGEJO_RELEASE_TOKEN unset — no release page created; tag + CHANGELOG remain the record${NC}\n" fi # Step 12: Push public docs to the soulcraft.com docs ingest door # (VENUE-DOCS-RELEASE-PUSH). Skips with a loud warning when # DOCS_INGEST_SECRET is unset; fails loudly (without undoing the publish — # that already happened) when a push errors, so the docs site never # silently trails npm. echo -e "${BLUE}1️⃣2️⃣ Pushing public docs to soulcraft.com/docs...${NC}" if node scripts/push-docs.js; then echo -e "${GREEN}✅ Docs push step done${NC}\n" else echo -e "${RED}❌ Docs push FAILED — soulcraft.com/docs trails npm until re-run or interim sync${NC}\n" fi echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e "${GREEN}🎉 Release ${NEW_VERSION} complete!${NC}" echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo "" echo -e "🏠 The Source: ${BLUE}https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v${NEW_VERSION}${NC}"