Merge pull request #98 from link-foundation/issue-96-829d21ddabc4 #182
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: JavaScript CI/CD | |
| on: | |
| push: | |
| branches: | |
| - main | |
| paths: | |
| - 'js/**' | |
| - 'scripts/**' | |
| - '.github/workflows/js.yml' | |
| pull_request: | |
| types: [opened, synchronize, reopened] | |
| paths: | |
| - 'js/**' | |
| - 'scripts/**' | |
| - '.github/workflows/js.yml' | |
| workflow_dispatch: | |
| inputs: | |
| release_mode: | |
| description: 'Manual release mode' | |
| required: true | |
| type: choice | |
| default: 'instant' | |
| options: | |
| - instant | |
| - changeset-pr | |
| bump_type: | |
| description: 'Manual release type' | |
| required: true | |
| type: choice | |
| options: | |
| - patch | |
| - minor | |
| - major | |
| description: | |
| description: 'Manual release description (optional)' | |
| required: false | |
| type: string | |
| concurrency: | |
| group: js-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| # === DETECT CHANGES === | |
| detect-changes: | |
| name: Detect Changes | |
| runs-on: ubuntu-latest | |
| if: github.event_name != 'workflow_dispatch' | |
| outputs: | |
| js-changed: ${{ steps.changes.outputs.js-changed }} | |
| mjs-changed: ${{ steps.changes.outputs.mjs-changed }} | |
| package-changed: ${{ steps.changes.outputs.package-changed }} | |
| docs-changed: ${{ steps.changes.outputs.docs-changed }} | |
| workflow-changed: ${{ steps.changes.outputs.workflow-changed }} | |
| any-js-code-changed: ${{ steps.changes.outputs.any-js-code-changed }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20.x' | |
| - name: Detect changes | |
| id: changes | |
| env: | |
| GITHUB_EVENT_NAME: ${{ github.event_name }} | |
| GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: node scripts/detect-code-changes.mjs | |
| # === VERSION CHANGE CHECK === | |
| # Prohibit manual version changes in package.json - versions should only be changed by CI/CD | |
| version-check: | |
| name: Check for Manual Version Changes | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'pull_request' | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Check for version changes in package.json | |
| run: | | |
| # Skip check for automated release PRs | |
| if [[ "${{ github.head_ref }}" == "changeset-release/"* ]] || [[ "${{ github.head_ref }}" == "changeset-js-"* ]]; then | |
| echo "Skipping version check for automated release PR" | |
| exit 0 | |
| fi | |
| # Get the diff for package.json | |
| VERSION_DIFF=$(git diff origin/${{ github.base_ref }}...HEAD -- js/package.json | grep -E '^\+.*"version"' || true) | |
| if [ -n "$VERSION_DIFF" ]; then | |
| echo "::error::Manual version change detected in js/package.json" | |
| echo "" | |
| echo "Version changes in package.json are prohibited in pull requests." | |
| echo "Versions are managed automatically by the CI/CD pipeline using changesets." | |
| echo "" | |
| echo "To request a release:" | |
| echo " 1. Add a changeset file describing your changes" | |
| echo " 2. The release workflow will automatically bump the version when merged" | |
| echo "" | |
| echo "Detected change:" | |
| echo "$VERSION_DIFF" | |
| exit 1 | |
| fi | |
| echo "No manual version changes detected - check passed" | |
| # === CHANGESET CHECK === | |
| changeset-check: | |
| name: Check for Changesets | |
| runs-on: ubuntu-latest | |
| needs: [detect-changes] | |
| if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-js-code-changed == 'true' | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: latest | |
| - name: Install dependencies | |
| working-directory: js | |
| run: bun install | |
| - name: Check for changesets | |
| env: | |
| GITHUB_BASE_REF: ${{ github.base_ref }} | |
| GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| # Skip changeset check for automated version PRs | |
| if [[ "${{ github.head_ref }}" == "changeset-release/"* ]]; then | |
| echo "Skipping changeset check for automated release PR" | |
| exit 0 | |
| fi | |
| # Run changeset validation script | |
| node scripts/validate-changeset.mjs | |
| # === LINT AND FORMAT CHECK === | |
| lint: | |
| name: Lint and Format Check | |
| runs-on: ubuntu-latest | |
| needs: [detect-changes] | |
| if: | | |
| github.event_name == 'push' || | |
| github.event_name == 'workflow_dispatch' || | |
| needs.detect-changes.outputs.js-changed == 'true' || | |
| needs.detect-changes.outputs.mjs-changed == 'true' || | |
| needs.detect-changes.outputs.package-changed == 'true' || | |
| needs.detect-changes.outputs.docs-changed == 'true' || | |
| needs.detect-changes.outputs.workflow-changed == 'true' | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: latest | |
| - name: Install dependencies | |
| working-directory: js | |
| run: bun install | |
| - name: Run ESLint | |
| working-directory: js | |
| run: bun run lint | |
| - name: Check formatting | |
| working-directory: js | |
| run: bun run format:check | |
| - name: Check file size limit | |
| run: node scripts/check-file-size.mjs | |
| # === TEST === | |
| test: | |
| name: Test (Bun on ${{ matrix.os }}) | |
| runs-on: ${{ matrix.os }} | |
| needs: [detect-changes, changeset-check] | |
| if: always() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, macos-latest, windows-latest] | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: latest | |
| - name: Install screen and tmux (Linux) | |
| if: runner.os == 'Linux' | |
| run: sudo apt-get update && sudo apt-get install -y screen tmux | |
| - name: Install screen and tmux (macOS) | |
| if: runner.os == 'macOS' | |
| run: brew install screen tmux | |
| - name: Setup .NET for clink (Linux) | |
| if: runner.os == 'Linux' | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: '8.0.x' | |
| - name: Install clink (Linux) | |
| if: runner.os == 'Linux' | |
| run: dotnet tool install --global clink | |
| - name: Setup .NET for clink (macOS) | |
| if: runner.os == 'macOS' | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: '8.0.x' | |
| - name: Install clink (macOS) | |
| if: runner.os == 'macOS' | |
| run: dotnet tool install --global clink | |
| - name: Add .NET tools to PATH | |
| if: runner.os != 'Windows' | |
| run: echo "$HOME/.dotnet/tools" >> $GITHUB_PATH | |
| - name: Install dependencies | |
| working-directory: js | |
| run: bun install | |
| - name: Run tests | |
| working-directory: js | |
| run: bun test | |
| # Test both execution modes (default and command-stream) | |
| - name: Test default execution mode | |
| working-directory: js | |
| run: | | |
| bun run src/bin/cli.js echo "Testing default mode" | |
| echo "Default mode test passed" | |
| - name: Test command-stream execution mode | |
| working-directory: js | |
| run: | | |
| bun run src/bin/cli.js --use-command-stream echo "Testing command-stream mode" | |
| echo "Command-stream mode test passed" | |
| - name: Test command-stream via env variable | |
| working-directory: js | |
| env: | |
| START_USE_COMMAND_STREAM: '1' | |
| run: | | |
| bun run src/bin/cli.js echo "Testing env var mode" | |
| echo "Env var mode test passed" | |
| # Test execution tracking (Linux/macOS) | |
| - name: Test execution tracking (Unix) | |
| if: runner.os != 'Windows' | |
| working-directory: js | |
| env: | |
| START_VERBOSE: '1' | |
| run: | | |
| bun run src/bin/cli.js echo "Testing execution tracking" | |
| ls -la ~/.start-command/ | |
| cat ~/.start-command/executions.lino | |
| echo "Execution tracking test passed" | |
| # Test execution tracking (Windows) | |
| - name: Test execution tracking (Windows) | |
| if: runner.os == 'Windows' | |
| working-directory: js | |
| env: | |
| START_VERBOSE: '1' | |
| shell: bash | |
| run: | | |
| bun run src/bin/cli.js echo "Testing execution tracking" | |
| ls -la "$USERPROFILE/.start-command/" | |
| cat "$USERPROFILE/.start-command/executions.lino" | |
| echo "Execution tracking test passed" | |
| # Integration tests for isolation modes - Linux only | |
| - name: Test screen isolation mode (Linux) | |
| if: runner.os == 'Linux' | |
| working-directory: js | |
| run: | | |
| bun run src/bin/cli.js --isolated screen -- echo "Testing screen isolation" | |
| echo "Screen isolation test passed" | |
| - name: Test tmux isolation mode (Linux) | |
| if: runner.os == 'Linux' | |
| working-directory: js | |
| run: | | |
| bun run src/bin/cli.js --isolated tmux -d -- echo "Testing tmux isolation" | |
| echo "Tmux isolation test passed" | |
| - name: Test docker isolation mode (Linux) | |
| if: runner.os == 'Linux' | |
| working-directory: js | |
| run: | | |
| bun run src/bin/cli.js --isolated docker -d --image alpine:latest -- echo "Testing docker isolation" | |
| echo "Docker isolation test passed" | |
| # SSH Integration Tests - Linux only | |
| - name: Setup SSH server for integration tests (Linux) | |
| if: runner.os == 'Linux' | |
| run: | | |
| sudo apt-get install -y openssh-server | |
| sudo systemctl start ssh | |
| ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -C "ci-test-key" | |
| cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys | |
| chmod 600 ~/.ssh/authorized_keys | |
| mkdir -p ~/.ssh | |
| cat >> ~/.ssh/config << 'EOF' | |
| Host localhost | |
| StrictHostKeyChecking no | |
| UserKnownHostsFile /dev/null | |
| LogLevel ERROR | |
| EOF | |
| chmod 600 ~/.ssh/config | |
| ssh localhost "echo 'SSH connection successful'" | |
| - name: Run SSH integration tests (Linux) | |
| if: runner.os == 'Linux' | |
| working-directory: js | |
| run: bun test test/ssh-integration.test.js | |
| # === CODE COVERAGE === | |
| # Fail if JavaScript test coverage drops below 80% | |
| coverage: | |
| name: Code Coverage (JavaScript) | |
| runs-on: ubuntu-latest | |
| needs: [detect-changes, changeset-check] | |
| if: always() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: latest | |
| - name: Install dependencies | |
| working-directory: js | |
| run: bun install | |
| - name: Run tests with coverage | |
| working-directory: js | |
| run: | | |
| bun test --coverage --coverage-reporter=text 2>&1 | tee coverage.txt || true | |
| # Extract coverage percentage from Bun output | |
| COVERAGE=$(grep -oP '\d+\.\d+(?=%)' coverage.txt | tail -1 || echo "0") | |
| echo "Coverage: ${COVERAGE}%" | |
| # Fail if coverage is below 45% | |
| node -e " | |
| const cov = parseFloat('${COVERAGE}'); | |
| if (isNaN(cov)) { console.log('Could not determine coverage, skipping check'); process.exit(0); } | |
| if (cov >= 45) { console.log('✅ Coverage ' + cov + '% meets the 45% threshold'); } | |
| else { console.error('❌ Coverage ' + cov + '% is below the 45% threshold'); process.exit(1); } | |
| " | |
| # === RELEASE === | |
| release: | |
| name: Release | |
| needs: [lint, test] | |
| if: always() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| id-token: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: latest | |
| - name: Setup Node.js for npm publishing | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20.x' | |
| registry-url: 'https://registry.npmjs.org' | |
| - name: Install dependencies | |
| working-directory: js | |
| run: bun install | |
| - name: Update npm for OIDC trusted publishing | |
| run: node scripts/setup-npm.mjs | |
| - name: Check for changesets | |
| id: check_changesets | |
| run: | | |
| CHANGESET_COUNT=$(find js/.changeset -name "*.md" ! -name "README.md" | wc -l) | |
| echo "Found $CHANGESET_COUNT changeset file(s)" | |
| echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT | |
| echo "changeset_count=$CHANGESET_COUNT" >> $GITHUB_OUTPUT | |
| - name: Merge multiple changesets | |
| if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1 | |
| run: node scripts/merge-changesets.mjs | |
| - name: Version packages and commit to main | |
| if: steps.check_changesets.outputs.has_changesets == 'true' | |
| id: version | |
| run: node scripts/version-and-commit.mjs --mode changeset --working-dir js | |
| - name: Publish to npm | |
| if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' | |
| id: publish | |
| run: node scripts/publish-to-npm.mjs --should-pull --working-dir js | |
| - name: Create GitHub Release | |
| if: steps.publish.outputs.published == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --prefix "js-" | |
| - name: Format GitHub release notes | |
| if: steps.publish.outputs.published == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}" --prefix "js-" | |
| # === MANUAL INSTANT RELEASE === | |
| instant-release: | |
| name: Instant Release | |
| if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| id-token: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: latest | |
| - name: Setup Node.js for npm publishing | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20.x' | |
| registry-url: 'https://registry.npmjs.org' | |
| - name: Install dependencies | |
| working-directory: js | |
| run: bun install | |
| - name: Update npm for OIDC trusted publishing | |
| run: node scripts/setup-npm.mjs | |
| - name: Version packages and commit to main | |
| id: version | |
| run: node scripts/version-and-commit.mjs --mode instant --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}" --working-dir js | |
| - name: Publish to npm | |
| if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' | |
| id: publish | |
| run: node scripts/publish-to-npm.mjs --working-dir js | |
| - name: Create GitHub Release | |
| if: steps.publish.outputs.published == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --prefix "js-" | |
| - name: Format GitHub release notes | |
| if: steps.publish.outputs.published == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}" --prefix "js-" | |
| # === MANUAL CHANGESET PR === | |
| changeset-pr: | |
| name: Create Changeset PR | |
| if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset-pr' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20.x' | |
| - name: Create changeset file | |
| run: node scripts/create-manual-changeset.mjs --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}" --working-dir js | |
| - name: Create Pull Request | |
| uses: peter-evans/create-pull-request@v7 | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| commit-message: 'chore(js): add changeset for manual ${{ github.event.inputs.bump_type }} release' | |
| branch: changeset-js-manual-release-${{ github.run_id }} | |
| delete-branch: true | |
| title: 'chore(js): manual ${{ github.event.inputs.bump_type }} release' | |
| body: | | |
| ## Manual JavaScript Release Request | |
| This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. | |
| ### Release Details | |
| - **Type:** ${{ github.event.inputs.bump_type }} | |
| - **Description:** ${{ github.event.inputs.description || 'Manual release' }} | |
| - **Triggered by:** @${{ github.actor }} | |
| ### Next Steps | |
| 1. Review the changeset in this PR | |
| 2. Merge this PR to main | |
| 3. The automated release workflow will publish to npm and create a GitHub release |