name: Release # Stable: run by hand from a dev-X.Y.Z branch. Beta: run by hand from a dev # branch, or let the Tuesday and Friday schedule snapshot the newest dev # branch when it moved since the last beta. on: schedule: - cron: "0 15 * * 2,5" workflow_dispatch: inputs: channel: description: "Stable release or beta" required: true default: Stable type: choice options: - Stable - Beta mode: description: "Release mode (Overwrite release is stable only)" required: true default: Everything type: choice options: - Everything - Overwrite release - Dry run - Skip submit permissions: contents: write concurrency: group: release cancel-in-progress: false jobs: prep: runs-on: blacksmith-2vcpu-ubuntu-2404 outputs: channel: ${{ steps.mode.outputs.channel }} mode: ${{ steps.mode.outputs.mode }} skip: ${{ steps.beta.outputs.skip || 'false' }} version: ${{ steps.info.outputs.version || steps.beta.outputs.version }} release_tag: ${{ steps.info.outputs.release_tag || steps.beta.outputs.release_tag }} mobile_version: ${{ steps.info.outputs.mobile_version }} mobile_tag: ${{ steps.info.outputs.mobile_tag }} dev_branch: ${{ steps.info.outputs.dev_branch || steps.beta.outputs.dev_branch }} sha: ${{ steps.beta.outputs.sha }} prev_ref: ${{ steps.beta.outputs.prev_ref }} steps: - name: Resolve channel and mode id: mode run: | CHANNEL="${{ inputs.channel || 'Beta' }}" MODE="${{ inputs.mode || 'Everything' }}" echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT" echo "mode=$MODE" >> "$GITHUB_OUTPUT" if [ "$CHANNEL" = "Beta" ] && [ "$MODE" = "Overwrite release" ]; then echo "Overwrite release is for stable releases only." exit 1 fi - name: Guard branch (release modes) if: ${{ github.event_name == 'workflow_dispatch' && steps.mode.outputs.mode != 'Overwrite release' && !startsWith(github.ref, 'refs/heads/dev-') }} run: | echo "This mode must be run from a dev branch (got ${{ github.ref }})." exit 1 - name: Guard branch (overwrite mode) if: ${{ steps.mode.outputs.mode == 'Overwrite release' && github.ref != 'refs/heads/main' }} run: | echo "Overwrite release must be run from main (got ${{ github.ref }})." exit 1 - name: Checkout repository uses: actions/checkout@v7 with: fetch-depth: 1 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" - name: Resolve versions id: info if: ${{ steps.mode.outputs.channel == 'Stable' }} env: GH_TOKEN: ${{ github.token }} run: | if [ "${{ steps.mode.outputs.mode }}" = "Overwrite release" ]; then DEV_BRANCH="" VERSION=$(node -p "require('./package.json').version") else DEV_BRANCH="${GITHUB_REF#refs/heads/}" VERSION=$(node scripts/parse-dev-branch.cjs "$DEV_BRANCH") fi echo "dev_branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "release_tag=v$VERSION" >> "$GITHUB_OUTPUT" MOBILE_RAW=$(gh release view -R Termix-SSH/Mobile --json tagName -q .tagName) MOBILE_VERSION=$(echo "$MOBILE_RAW" | sed -E 's/^release-//; s/-tag$//; s/^v//') if [ -z "$MOBILE_VERSION" ]; then echo "Failed to resolve the latest Termix-SSH/Mobile release version." exit 1 fi echo "mobile_version=$MOBILE_VERSION" >> "$GITHUB_OUTPUT" echo "mobile_tag=$MOBILE_RAW" >> "$GITHUB_OUTPUT" - name: Check CHANGELOG.md has notes for this version if: ${{ steps.mode.outputs.channel == 'Stable' }} run: | node scripts/generate-release-body.cjs \ --version "${{ steps.info.outputs.version }}" \ --mobile-version "${{ steps.info.outputs.mobile_version }}" \ --mobile-tag "${{ steps.info.outputs.mobile_tag }}" > /dev/null - name: Resolve beta version id: beta if: ${{ steps.mode.outputs.channel == 'Beta' }} env: GH_TOKEN: ${{ secrets.TERMIX_PAT }} run: | if [ "${{ github.event_name }}" = "schedule" ]; then REFS=$(gh api "repos/${{ github.repository }}/branches" --paginate -q '.[].name') if ! DEV_BRANCH=$(printf '%s\n' "$REFS" | node scripts/latest-dev-branch.cjs 2>/dev/null); then echo "No dev-X.Y.Z branch is open, so no beta today." echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 fi else DEV_BRANCH="${GITHUB_REF#refs/heads/}" fi BASE=$(node scripts/parse-dev-branch.cjs "$DEV_BRANCH") SHA=$(gh api "repos/${{ github.repository }}/branches/$DEV_BRANCH" -q .commit.sha) TAGS=$(gh api "repos/${{ github.repository }}/git/matching-refs/tags/v$BASE-beta." -q '.[].ref' | sed 's#^refs/tags/##') LATEST=$(printf '%s\n' "$TAGS" | node scripts/beta-version.cjs "$BASE" --latest) VERSION=$(printf '%s\n' "$TAGS" | node scripts/beta-version.cjs "$BASE") if [ -n "$LATEST" ]; then LATEST_SHA=$(gh api "repos/${{ github.repository }}/commits/$LATEST" -q .sha) if [ "$LATEST_SHA" = "$SHA" ] && [ "${{ github.event_name }}" = "schedule" ]; then echo "$DEV_BRANCH has not moved since $LATEST, so no beta today." echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 fi PREV="$LATEST" else PREV=$(gh release view --repo ${{ github.repository }} --json tagName -q .tagName 2>/dev/null || true) fi echo "Beta $VERSION from $DEV_BRANCH @ $SHA, changes since ${PREV:-nothing}" echo "skip=false" >> "$GITHUB_OUTPUT" echo "dev_branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "release_tag=v$VERSION" >> "$GITHUB_OUTPUT" echo "sha=$SHA" >> "$GITHUB_OUTPUT" echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT" verify-beta: needs: [prep] if: ${{ needs.prep.outputs.channel == 'Beta' && needs.prep.outputs.skip != 'true' }} runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout beta commit uses: actions/checkout@v7 with: ref: ${{ needs.prep.outputs.sha }} fetch-depth: 1 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" cache: "npm" - name: Install dependencies run: npm ci - name: Build plugin SDK run: npm run build:sdk - name: Lint run: npm run lint - name: Type check run: npm run type-check - name: Build run: npm run build - name: Run unit tests run: npm run test normalize: needs: [prep] if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Overwrite release' }} runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout dev branch uses: actions/checkout@v7 with: ref: ${{ needs.prep.outputs.dev_branch }} fetch-depth: 0 token: ${{ secrets.TERMIX_PAT }} - name: Setup Node.js uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" cache: "npm" - name: Install dependencies run: npm ci # Manifest validation in lint loads the built SDK. - name: Build SDK run: npm run build:sdk - name: Lint and format run: | npm run lint:fix npm run format npm run lint - name: Run unit tests run: npm run test - name: Sync version run: node scripts/sync-version.cjs --version "${{ needs.prep.outputs.version }}" - name: Commit changes to dev branch run: | git config user.name "LukeGus" git config user.email "bugattiguy527@gmail.com" if git diff --quiet; then echo "No lint/format/version changes to commit." exit 0 fi if [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then echo "DRY RUN: would commit and push the following changes to ${{ needs.prep.outputs.dev_branch }}:" git --no-pager diff --stat exit 0 fi git add -A git commit -m "chore: lint, format, and bump version to ${{ needs.prep.outputs.version }}" git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}" crowdin: needs: [prep, normalize] if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Overwrite release' }} runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout dev branch uses: actions/checkout@v7 with: ref: ${{ needs.prep.outputs.dev_branch }} fetch-depth: 0 token: ${{ secrets.TERMIX_PAT }} - name: Setup Node.js uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" - name: Merge main into dev branch run: | git config user.name "LukeGus" git config user.email "bugattiguy527@gmail.com" git fetch origin main git merge origin/main -X ours --no-edit || true git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}" - name: Clean up stale Crowdin git integration branch continue-on-error: true env: GH_TOKEN: ${{ secrets.TERMIX_PAT }} run: | PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head i18n_translate --state open --json number -q '.[0].number' || true) if [ -n "$PR_NUMBER" ]; then gh pr close "$PR_NUMBER" --repo ${{ github.repository }} --delete-branch || true fi git push origin --delete i18n_translate || true - name: Upload sources to Crowdin uses: crowdin/github-action@v3 with: upload_sources: true upload_translations: false download_translations: false create_pull_request: false push_translations: false token: ${{ secrets.CROWDIN_API_KEY }} project_id: "858252" env: CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }} # Off until the machine translation credits are topped up. - name: Pre-translate untranslated strings if: false env: CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }} run: node scripts/crowdin-pretranslate.cjs - name: Download translations from Crowdin uses: crowdin/github-action@v3 with: upload_sources: false upload_translations: false download_translations: true create_pull_request: false push_translations: false token: ${{ secrets.CROWDIN_API_KEY }} project_id: "858252" env: CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }} - name: Commit translations to dev branch run: | git config user.name "LukeGus" git config user.email "bugattiguy527@gmail.com" git add src/ui/locales/translated if git diff --cached --quiet; then echo "No translation changes to commit." exit 0 fi git commit -m "chore: sync Crowdin translations for ${{ needs.prep.outputs.version }}" git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}" merge-to-main: needs: [prep, normalize, crowdin] if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.channel == 'Stable' && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.crowdin.result == 'success' || needs.crowdin.result == 'skipped') }} runs-on: blacksmith-2vcpu-ubuntu-2404 outputs: main_sha: ${{ steps.merge.outputs.main_sha }} build_ref: ${{ steps.merge.outputs.build_ref }} steps: - name: Checkout repository uses: actions/checkout@v7 with: fetch-depth: 1 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" - name: Generate PR body id: body run: | node scripts/generate-release-body.cjs \ --version "${{ needs.prep.outputs.version }}" \ --mobile-version "${{ needs.prep.outputs.mobile_version }}" \ --mobile-tag "${{ needs.prep.outputs.mobile_tag }}" > PR_BODY.md - name: Open and squash-merge PR to main id: merge env: GH_TOKEN: ${{ secrets.TERMIX_PAT }} run: | DEV_BRANCH="${{ needs.prep.outputs.dev_branch }}" TITLE="v${{ needs.prep.outputs.version }}" if [ "${{ needs.prep.outputs.mode }}" = "Overwrite release" ]; then echo "OVERWRITE: no merge; building from main as-is." echo "build_ref=main" >> "$GITHUB_OUTPUT" echo "main_sha=$(gh api repos/${{ github.repository }}/commits/main -q .sha)" >> "$GITHUB_OUTPUT" exit 0 fi if [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then echo "DRY RUN: would open and squash-merge a PR from $DEV_BRANCH into main." echo "DRY RUN: downstream jobs will build from $DEV_BRANCH instead of main." echo "build_ref=$DEV_BRANCH" >> "$GITHUB_OUTPUT" echo "main_sha=" >> "$GITHUB_OUTPUT" exit 0 fi PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head "$DEV_BRANCH" --base main --state open --json number -q '.[0].number' || true) if [ -z "$PR_NUMBER" ]; then PR_NUMBER=$(gh pr create --repo ${{ github.repository }} \ --base main --head "$DEV_BRANCH" \ --title "$TITLE" --body-file PR_BODY.md \ | grep -oE '[0-9]+$') fi gh pr merge "$PR_NUMBER" --repo ${{ github.repository }} --squash --admin STATE=$(gh pr view "$PR_NUMBER" --repo ${{ github.repository }} --json state -q .state) if [ "$STATE" != "MERGED" ]; then echo "PR #$PR_NUMBER did not merge (state: $STATE)." exit 1 fi MAIN_SHA=$(gh api repos/${{ github.repository }}/commits/main -q .sha) echo "main_sha=$MAIN_SHA" >> "$GITHUB_OUTPUT" echo "build_ref=$MAIN_SHA" >> "$GITHUB_OUTPUT" docker: needs: [prep, verify-beta, merge-to-main] if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.skip != 'true' && (needs.merge-to-main.result == 'success' || needs.verify-beta.result == 'success') }} uses: ./.github/workflows/docker.yml with: version: ${{ needs.prep.outputs.version }} build_type: ${{ needs.prep.outputs.channel == 'Beta' && 'Beta' || 'Production' }} dry_run: ${{ needs.prep.outputs.mode == 'Dry run' }} source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.prep.outputs.sha }} secrets: inherit create-release: needs: [prep, merge-to-main, docker] if: ${{ always() && needs.prep.outputs.channel == 'Stable' && needs.docker.result == 'success' && needs.prep.outputs.mode != 'Dry run' }} runs-on: blacksmith-2vcpu-ubuntu-2404 permissions: contents: write steps: - name: Checkout main uses: actions/checkout@v7 with: ref: main fetch-depth: 1 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" - name: Clear existing release artifacts (overwrite mode) if: ${{ needs.prep.outputs.mode == 'Overwrite release' }} env: GH_TOKEN: ${{ secrets.TERMIX_PAT }} run: | TAG="${{ needs.prep.outputs.release_tag }}" if ! gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then echo "Overwrite release: no existing release $TAG to overwrite." exit 1 fi echo "Clearing existing assets from $TAG..." gh release view "$TAG" --repo ${{ github.repository }} --json assets -q '.assets[].name' | while read -r ASSET; do [ -n "$ASSET" ] && gh release delete-asset "$TAG" "$ASSET" --repo ${{ github.repository }} --yes || true done - name: Generate release body if: ${{ needs.prep.outputs.mode != 'Overwrite release' }} run: | node scripts/generate-release-body.cjs \ --version "${{ needs.prep.outputs.version }}" \ --mobile-version "${{ needs.prep.outputs.mobile_version }}" \ --mobile-tag "${{ needs.prep.outputs.mobile_tag }}" > RELEASE_BODY.md - name: Create or update GitHub release if: ${{ needs.prep.outputs.mode != 'Overwrite release' }} env: GH_TOKEN: ${{ secrets.TERMIX_PAT }} run: | TAG="${{ needs.prep.outputs.release_tag }}" TITLE="Termix v${{ needs.prep.outputs.version }}" if gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then gh release edit "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md else gh release create "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md --target "${{ needs.merge-to-main.outputs.main_sha }}" fi create-beta-release: needs: [prep, docker] if: ${{ always() && needs.prep.outputs.channel == 'Beta' && needs.docker.result == 'success' && needs.prep.outputs.mode != 'Dry run' }} runs-on: blacksmith-2vcpu-ubuntu-2404 permissions: contents: write steps: - name: Checkout beta commit uses: actions/checkout@v7 with: ref: ${{ needs.prep.outputs.sha }} fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" - name: Generate beta release body run: | node scripts/generate-beta-body.cjs \ --version "${{ needs.prep.outputs.version }}" \ --branch "${{ needs.prep.outputs.dev_branch }}" \ --sha "${{ needs.prep.outputs.sha }}" \ --prev "${{ needs.prep.outputs.prev_ref }}" > BETA_BODY.md - name: Create beta release env: GH_TOKEN: ${{ secrets.TERMIX_PAT }} run: | VERSION="${{ needs.prep.outputs.version }}" TITLE="Termix v${VERSION%-beta.*} Beta ${VERSION##*-beta.}" TAG="${{ needs.prep.outputs.release_tag }}" if gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then gh release edit "$TAG" --repo ${{ github.repository }} \ --title "$TITLE" --notes-file BETA_BODY.md --prerelease else gh release create "$TAG" --repo ${{ github.repository }} \ --title "$TITLE" --notes-file BETA_BODY.md \ --prerelease --target "${{ needs.prep.outputs.sha }}" fi electron-release: needs: [prep, merge-to-main, docker, create-release, create-beta-release] if: ${{ always() && needs.prep.outputs.skip != 'true' && needs.docker.result == 'success' && needs.create-release.result != 'failure' && needs.create-beta-release.result != 'failure' }} uses: ./.github/workflows/electron.yml with: build_type: all artifact_destination: ${{ needs.prep.outputs.mode == 'Dry run' && 'file' || 'release' }} release_tag: ${{ needs.prep.outputs.release_tag }} version_override: ${{ needs.prep.outputs.channel == 'Beta' && needs.prep.outputs.version || '' }} source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.prep.outputs.sha }} secrets: inherit electron-submit: needs: [prep, merge-to-main, electron-release] if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Skip submit' }} uses: ./.github/workflows/electron.yml with: build_type: all artifact_destination: submit source_ref: ${{ needs.merge-to-main.outputs.build_ref }} secrets: inherit cask-commit-back: needs: [prep, electron-release] if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' }} runs-on: blacksmith-2vcpu-ubuntu-2404 permissions: contents: write steps: - name: Checkout main uses: actions/checkout@v7 with: ref: main fetch-depth: 1 token: ${{ secrets.TERMIX_PAT }} - name: Bump and commit Homebrew cask env: VERSION: ${{ needs.prep.outputs.version }} DMG_SHA256: ${{ needs.electron-release.outputs.macos_universal_dmg_sha256 }} run: | if [ -z "$DMG_SHA256" ]; then echo "No universal DMG checksum available (macOS build unsigned or skipped); leaving cask unchanged." exit 0 fi git config user.name "LukeGus" git config user.email "bugattiguy527@gmail.com" git fetch origin main git checkout -B main origin/main sed -i "s|version \".*\"|version \"$VERSION\"|g" Casks/termix.rb sed -i "s|sha256 \".*\"|sha256 \"$DMG_SHA256\"|g" Casks/termix.rb git add Casks/termix.rb if git diff --cached --quiet; then echo "Cask already up to date." exit 0 fi git commit -m "chore: bump Homebrew cask to $VERSION" git push origin HEAD:main prune-betas: needs: [prep, create-release] if: ${{ always() && needs.prep.outputs.channel == 'Stable' && needs.create-release.result == 'success' && needs.prep.outputs.mode != 'Overwrite release' }} runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Delete this version's beta releases (tags stay) env: GH_TOKEN: ${{ secrets.TERMIX_PAT }} run: | PREFIX="v${{ needs.prep.outputs.version }}-beta." gh release list --repo ${{ github.repository }} --limit 200 --json tagName -q '.[].tagName' \ | grep -F "$PREFIX" | while read -r TAG; do echo "Deleting release $TAG" gh release delete "$TAG" --repo ${{ github.repository }} --yes || true done cleanup: needs: [prep, merge-to-main, electron-release, cask-commit-back] if: ${{ always() && needs.prep.outputs.channel == 'Stable' && (needs.prep.outputs.mode == 'Everything' || needs.prep.outputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' }} runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout repository uses: actions/checkout@v7 with: fetch-depth: 1 persist-credentials: false - name: Delete dev branch in Termix env: GH_TOKEN: ${{ secrets.TERMIX_PAT }} run: | git push https://x-access-token:${{ secrets.TERMIX_PAT }}@github.com/${{ github.repository }}.git \ --delete "${{ needs.prep.outputs.dev_branch }}" || true