name: Release on: workflow_dispatch: inputs: bump: description: Semver part to bump when set_version is empty. required: true default: patch type: choice options: - patch - minor - major set_version: description: Exact X.Y.Z version to release instead of bumping. Set to the current version to resume a failed publish. required: false type: string base_branch: description: Branch to release from and merge the release PR into. required: true default: develop type: string target_branch: description: Publish target. Use main for releases; use build-test only to test CI/CD or build changes. required: true default: main type: choice options: - main - build-test dry_run: description: Show the release version changes without pushing, merging, or publishing. required: true default: false type: boolean publish: description: Publish the GitHub Release. Set to false to leave it as a draft. required: true default: true type: boolean create_release: description: Create the GitHub Release after pushing the tag. required: true default: true type: boolean auto_merge: description: Merge the release PR immediately, then publish. Set to false to only prepare the PR. required: true default: true type: boolean permissions: contents: write pull-requests: write concurrency: group: release-${{ inputs.base_branch }}-${{ inputs.target_branch }} cancel-in-progress: false jobs: release-pr: name: Release PR runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} source_sha: ${{ steps.publish_source.outputs.sha }} steps: - name: Check out base branch uses: actions/checkout@v4 with: ref: ${{ inputs.base_branch }} fetch-depth: 0 persist-credentials: false - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "22" - name: Configure release credentials env: RELEASE_ORCHESTRATOR_TOKEN: ${{ secrets.RELEASE_ORCHESTRATOR_TOKEN }} PREPARE_RELEASE_TOKEN: ${{ secrets.PREPARE_RELEASE_TOKEN }} PUBLISH_PUSH_TOKEN: ${{ secrets.PUBLISH_PUSH_TOKEN }} BUILD_TEST_PUSH_TOKEN: ${{ secrets.BUILD_TEST_PUSH_TOKEN }} GITHUB_TOKEN: ${{ github.token }} run: | release_token="${RELEASE_ORCHESTRATOR_TOKEN:-${PREPARE_RELEASE_TOKEN:-${PUBLISH_PUSH_TOKEN:-${BUILD_TEST_PUSH_TOKEN:-$GITHUB_TOKEN}}}}" if [ -n "${RELEASE_ORCHESTRATOR_TOKEN:-}" ]; then echo "Using RELEASE_ORCHESTRATOR_TOKEN for release orchestration." elif [ -n "${PREPARE_RELEASE_TOKEN:-}" ]; then echo "Using PREPARE_RELEASE_TOKEN for release orchestration." elif [ -n "${PUBLISH_PUSH_TOKEN:-}" ]; then echo "Using PUBLISH_PUSH_TOKEN for release orchestration." elif [ -n "${BUILD_TEST_PUSH_TOKEN:-}" ]; then echo "Using BUILD_TEST_PUSH_TOKEN for release orchestration." else echo "Using GITHUB_TOKEN for release orchestration." fi git remote set-url origin "https://x-access-token:${release_token}@github.com/${GITHUB_REPOSITORY}.git" { echo "GH_TOKEN=$release_token" echo "RELEASE_TOKEN=$release_token" } >> "$GITHUB_ENV" - name: Prepare canonical version id: version env: BUMP: ${{ inputs.bump }} SET_VERSION: ${{ inputs.set_version }} run: | if [ -n "$SET_VERSION" ]; then scripts/release/bump-version.sh --set-version "$SET_VERSION" --no-commit else scripts/release/bump-version.sh "$BUMP" --no-commit fi node scripts/release/sync-version.mjs version="$(tr -d '[:space:]' < plugins/cad/VERSION)" echo "version=$version" >> "$GITHUB_OUTPUT" if git diff --quiet; then echo "changed=false" >> "$GITHUB_OUTPUT" echo "Base branch already contains release version $version; skipping the release PR." else echo "changed=true" >> "$GITHUB_OUTPUT" echo "Prepared release version: $version" fi - name: Check release version metadata env: CHANGED: ${{ steps.version.outputs.changed }} run: | node scripts/release/sync-version.mjs --check if [ "$CHANGED" = "true" ]; then scripts/release/check-version.sh --incremented-from origin/main latest_tag="$(git tag --list '[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | head -n 1 || true)" if [ -n "$latest_tag" ]; then scripts/release/check-version.sh --incremented-from "refs/tags/$latest_tag" fi else scripts/release/check-version.sh echo "No version metadata changes; the publish gate decides whether this version still needs to ship." fi - name: Check development symlink layout run: scripts/dev/setup-symlinks.sh --check - name: Show dry-run diff if: inputs.dry_run run: | git diff --stat git diff -- plugins/cad/VERSION - name: Stop after dry run if: inputs.dry_run run: echo "Dry run requested; skipping release branch, PR merge, and publish." - name: Create or update release pull request if: ${{ !inputs.dry_run && steps.version.outputs.changed == 'true' }} id: release_pr env: BASE_BRANCH: ${{ inputs.base_branch }} VERSION: ${{ steps.version.outputs.version }} run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" branch="release/$VERSION" body="Bumps plugins/cad/VERSION and derived package/plugin metadata to $VERSION. This PR was created by the one-button Release workflow, which merges it into $BASE_BRANCH immediately; the publish job revalidates the full production bundle before anything ships to the target branch." git checkout -B "$branch" git add -A git commit -m "Release $VERSION" git push --force-with-lease origin "$branch" if pr_json="$(gh pr view "$branch" --repo "$GITHUB_REPOSITORY" --json number,url 2>/dev/null)"; then pr_number="$(printf '%s\n' "$pr_json" | jq -r '.number')" pr_url="$(printf '%s\n' "$pr_json" | jq -r '.url')" gh pr edit "$pr_number" \ --repo "$GITHUB_REPOSITORY" \ --base "$BASE_BRANCH" \ --title "Release $VERSION" \ --body "$body" else pr_url="$(gh pr create \ --repo "$GITHUB_REPOSITORY" \ --base "$BASE_BRANCH" \ --head "$branch" \ --title "Release $VERSION" \ --body "$body")" pr_number="$(gh pr view "$pr_url" --repo "$GITHUB_REPOSITORY" --json number --jq '.number')" fi echo "created=true" >> "$GITHUB_OUTPUT" echo "number=$pr_number" >> "$GITHUB_OUTPUT" echo "url=$pr_url" >> "$GITHUB_OUTPUT" echo "Release PR: $pr_url" - name: Merge release pull request if: ${{ !inputs.dry_run && inputs.auto_merge && steps.release_pr.outputs.created == 'true' }} env: PR_NUMBER: ${{ steps.release_pr.outputs.number }} run: | pr_json="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefOid)" head_sha="$(printf '%s\n' "$pr_json" | jq -r '.headRefOid')" merged=false for attempt in $(seq 1 6); do if gh api \ --method PUT \ "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/merge" \ -f merge_method=merge \ -f sha="$head_sha"; then merged=true break fi echo "Merge attempt $attempt failed; retrying while GitHub computes mergeability..." sleep 10 done if [ "$merged" != "true" ]; then echo "Could not merge release PR #$PR_NUMBER." >&2 exit 1 fi - name: Stop before publish when auto-merge is disabled if: ${{ !inputs.dry_run && !inputs.auto_merge }} run: echo "auto_merge=false; the release PR was prepared but the publish jobs were skipped." - name: Resolve publish source if: ${{ !inputs.dry_run && inputs.auto_merge }} id: publish_source env: BASE_BRANCH: ${{ inputs.base_branch }} run: | git fetch --no-tags origin "+$BASE_BRANCH:refs/remotes/origin/$BASE_BRANCH" source_sha="$(git rev-parse "origin/$BASE_BRANCH^{commit}")" echo "sha=$source_sha" >> "$GITHUB_OUTPUT" echo "Publishing $BASE_BRANCH at $source_sha." publish: name: Publish needs: release-pr if: ${{ !inputs.dry_run && inputs.auto_merge }} runs-on: ubuntu-latest outputs: published: ${{ steps.push.outputs.published }} publish_sha: ${{ steps.commit.outputs.publish_sha }} steps: - name: Check out publish source uses: actions/checkout@v4 with: ref: ${{ needs.release-pr.outputs.source_sha }} fetch-depth: 0 persist-credentials: false - name: Configure publish credentials env: PUBLISH_PUSH_TOKEN: ${{ secrets.PUBLISH_PUSH_TOKEN }} BUILD_TEST_PUSH_TOKEN: ${{ secrets.BUILD_TEST_PUSH_TOKEN }} GH_TOKEN: ${{ github.token }} run: | if [ -n "$PUBLISH_PUSH_TOKEN" ]; then git remote set-url origin "https://x-access-token:${PUBLISH_PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" echo "Using PUBLISH_PUSH_TOKEN for publish push." elif [ -n "$BUILD_TEST_PUSH_TOKEN" ]; then git remote set-url origin "https://x-access-token:${BUILD_TEST_PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" echo "Using BUILD_TEST_PUSH_TOKEN for publish push." else git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" echo "Using GITHUB_TOKEN for publish push." fi - name: Check canonical release version run: scripts/release/check-version.sh - name: Evaluate release gate id: gate env: BASE_BRANCH: ${{ inputs.base_branch }} TARGET_BRANCH: ${{ inputs.target_branch }} run: | source_sha="$(git rev-parse HEAD)" echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT" if [ "$TARGET_BRANCH" != "main" ]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "Build-test publish requested for $source_sha (CI/CD or build testing only)." exit 0 fi if [ "$BASE_BRANCH" != "develop" ]; then echo "Real releases must publish develop to main; got base_branch=$BASE_BRANCH." >&2 exit 2 fi current_version="$(tr -d '[:space:]' < plugins/cad/VERSION)" latest_tag="$(git tag --list '[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | head -n 1 || true)" if ! scripts/release/check-version.sh --incremented-from origin/main >/tmp/publish-main-version-check.log 2>&1; then main_version="$(git show origin/main:plugins/cad/VERSION | tr -d '[:space:]')" if [ "$main_version" = "$current_version" ] && ! git rev-parse --verify --quiet "refs/tags/$current_version" >/dev/null; then echo "Release version already reached origin/main, but tag $current_version is missing; resuming publish." else echo "should_publish=false" >> "$GITHUB_OUTPUT" echo "Skipping publish: version has not advanced past origin/main." exit 0 fi fi if [ -n "$latest_tag" ] && ! scripts/release/check-version.sh --incremented-from "refs/tags/$latest_tag" >/tmp/publish-tag-version-check.log 2>&1; then echo "should_publish=false" >> "$GITHUB_OUTPUT" echo "Skipping publish: version has not advanced past latest tag $latest_tag." exit 0 fi echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "Release version advanced; publishing $source_sha to $TARGET_BRANCH." - name: Skip non-release publish if: steps.gate.outputs.should_publish != 'true' run: echo "Publish only updates main when the source version is newer than main and the latest release tag." - name: Validate publish target if: steps.gate.outputs.should_publish == 'true' env: SOURCE_SHA: ${{ steps.gate.outputs.source_sha }} TARGET_BRANCH: ${{ inputs.target_branch }} run: | scripts/release/check-publish-source.sh --source-ref "$SOURCE_SHA" --target-ref origin/main if [ "$TARGET_BRANCH" = "main" ]; then echo "Validated main publish target." else echo "Validated build-test target against origin/main." fi - name: Set up dependencies if: steps.gate.outputs.should_publish == 'true' uses: ./.github/actions/setup-deps - name: Check development symlink layout if: steps.gate.outputs.should_publish == 'true' run: scripts/dev/setup-symlinks.sh --check - name: Bundle production outputs if: steps.gate.outputs.should_publish == 'true' run: scripts/bundle/bundle.sh --clean - name: Validate production bundle layout if: steps.gate.outputs.should_publish == 'true' run: scripts/github-workflows/check-builds.sh --skip-bundle-check - name: Run documentation checks if: steps.gate.outputs.should_publish == 'true' run: scripts/test/test-docs.sh - name: Run code tests if: steps.gate.outputs.should_publish == 'true' run: scripts/test/test.sh - name: Remove models from publish tree if: steps.gate.outputs.should_publish == 'true' run: | rm -rf models if [ -e models ]; then echo "Failed to remove models from the publish tree." >&2 exit 1 fi - name: Commit publish result if: steps.gate.outputs.should_publish == 'true' id: commit env: BASE_BRANCH: ${{ inputs.base_branch }} SOURCE_SHA: ${{ steps.gate.outputs.source_sha }} TARGET_BRANCH: ${{ inputs.target_branch }} run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A if [ -n "$(git ls-files -- models)" ]; then echo "models/ must not be present in the publish commit." >&2 git ls-files -- models >&2 exit 1 fi version="$(tr -d '[:space:]' < plugins/cad/VERSION)" if target_base_sha="$(git rev-parse --verify "origin/$TARGET_BRANCH^{commit}" 2>/dev/null)"; then echo "Using existing $TARGET_BRANCH as publish parent: $target_base_sha" else target_base_sha="$(git rev-parse --verify "origin/main^{commit}")" echo "Target branch $TARGET_BRANCH does not exist; using origin/main as publish parent: $target_base_sha" fi release_base_sha="$(git rev-parse --verify "origin/main^{commit}")" previous_source_sha="$(scripts/release/check-publish-source.sh --target-ref origin/main --print-previous-source)" included_commits="$(git log --oneline --no-decorate "$previous_source_sha..$SOURCE_SHA" || true)" message_file="$(mktemp)" { echo "Publish $version from $BASE_BRANCH to $TARGET_BRANCH" echo echo "Source ref: $BASE_BRANCH" echo "Source commit: $SOURCE_SHA" echo "Target branch: $TARGET_BRANCH" echo "Previous target: $target_base_sha" echo "Release base: $release_base_sha" echo "Previous source: $previous_source_sha" echo echo "Included commits since previous source:" if [ -n "$included_commits" ]; then echo "$included_commits" else echo "(no source commits beyond previous published source)" fi } > "$message_file" tree_sha="$(git write-tree)" commit_parents=(-p "$target_base_sha") if [ "$SOURCE_SHA" != "$target_base_sha" ]; then commit_parents+=(-p "$SOURCE_SHA") fi publish_commit="$(git commit-tree "$tree_sha" "${commit_parents[@]}" -F "$message_file")" git reset --hard "$publish_commit" echo "publish_sha=$publish_commit" >> "$GITHUB_OUTPUT" echo "Created publish commit: $publish_commit" - name: Push target branch if: steps.gate.outputs.should_publish == 'true' id: push env: TARGET_BRANCH: ${{ inputs.target_branch }} run: | if [ "$TARGET_BRANCH" = "main" ]; then git push origin HEAD:main else git push --force-with-lease origin "HEAD:$TARGET_BRANCH" fi echo "published=true" >> "$GITHUB_OUTPUT" upload-models: name: Upload Models needs: - release-pr - publish if: ${{ needs.publish.outputs.published == 'true' && inputs.target_branch == 'main' }} uses: ./.github/workflows/upload-models.yml with: ref: ${{ needs.release-pr.outputs.source_sha }} secrets: inherit deploy-docs: name: Deploy Docs needs: publish if: ${{ needs.publish.outputs.published == 'true' && inputs.target_branch == 'main' }} uses: ./.github/workflows/deploy-docs.yml with: ref: ${{ needs.publish.outputs.publish_sha }} secrets: inherit deploy-viewer: name: Deploy Viewer needs: publish if: ${{ needs.publish.outputs.published == 'true' && inputs.target_branch == 'main' }} uses: ./.github/workflows/deploy-viewer.yml with: ref: ${{ needs.publish.outputs.publish_sha }} secrets: inherit tag-release: name: Tag and GitHub Release needs: - publish - upload-models - deploy-docs - deploy-viewer if: ${{ needs.publish.outputs.published == 'true' && inputs.target_branch == 'main' }} runs-on: ubuntu-latest steps: - name: Check out publish commit uses: actions/checkout@v4 with: ref: ${{ needs.publish.outputs.publish_sha }} fetch-depth: 0 persist-credentials: false - name: Configure tag push credentials env: PUBLISH_PUSH_TOKEN: ${{ secrets.PUBLISH_PUSH_TOKEN }} GH_TOKEN: ${{ github.token }} run: | if [ -n "$PUBLISH_PUSH_TOKEN" ]; then git remote set-url origin "https://x-access-token:${PUBLISH_PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" echo "Using PUBLISH_PUSH_TOKEN for tag push." else git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" echo "Using GITHUB_TOKEN for tag push." fi - name: Create release tag and GitHub Release env: GH_TOKEN: ${{ github.token }} INPUT_PUBLISH: ${{ inputs.publish }} INPUT_CREATE_RELEASE: ${{ inputs.create_release }} run: | args=(--target HEAD) if [ "$INPUT_PUBLISH" = "true" ]; then args+=(--publish) fi if [ "$INPUT_CREATE_RELEASE" != "true" ]; then args+=(--skip-release) fi scripts/release/publish-github-release.sh "${args[@]}"