diff --git a/.github/workflows/changeset.yaml b/.github/workflows/changeset.yaml new file mode 100644 index 00000000..47d424ce --- /dev/null +++ b/.github/workflows/changeset.yaml @@ -0,0 +1,175 @@ +# Copyright 2025 LiveKit, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Changeset Check + +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + +jobs: + changeset: + name: Changeset & Breaking Change Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for changeset entries + id: changeset + run: | + count=$(find .changes -maxdepth 1 -type f ! -name '.*' 2>/dev/null | wc -l | tr -d ' ') + echo "count=$count" >> "$GITHUB_OUTPUT" + + has_major=false + if [ "$count" -gt 0 ]; then + if grep -rq '^major ' .changes/ 2>/dev/null; then + has_major=true + fi + fi + echo "has_major=$has_major" >> "$GITHUB_OUTPUT" + + - uses: ./.github/actions/setup-flutter + + - name: Detect breaking changes + id: breaking + run: | + BASE_TAG=$(git describe --tags --abbrev=0 origin/main 2>/dev/null || echo "") + if [ -z "$BASE_TAG" ]; then + echo "No base tag found, skipping breaking change detection" + echo "has_breaking=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Comparing public API against $BASE_TAG" + + dart pub global activate dart_apitool + REPO_URL="git://${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}:${BASE_TAG}" + + DIFF_OUTPUT=$(dart-apitool diff \ + --old "$REPO_URL" \ + --new . \ + --force-use-flutter 2>&1) || true + + echo "--- dart-apitool output ---" + echo "$DIFF_OUTPUT" + echo "---" + + # Extract breaking changes from the output + BREAKING_LINES=$(echo "$DIFF_OUTPUT" | grep -i "breaking" || true) + + if [ -n "$BREAKING_LINES" ]; then + echo "has_breaking=true" >> "$GITHUB_OUTPUT" + echo "$DIFF_OUTPUT" > "$RUNNER_TEMP/breaking_changes.txt" + else + echo "has_breaking=false" >> "$GITHUB_OUTPUT" + echo "No breaking changes detected" + fi + + - name: Manage PR comments + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const changesetCount = parseInt('${{ steps.changeset.outputs.count }}'); + const hasMajor = '${{ steps.changeset.outputs.has_major }}' === 'true'; + const hasBreaking = '${{ steps.breaking.outputs.has_breaking }}' === 'true'; + + let details = ''; + if (hasBreaking) { + const detailsPath = path.join(process.env.RUNNER_TEMP, 'breaking_changes.txt'); + try { details = fs.readFileSync(detailsPath, 'utf8').trim(); } catch {} + } + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + // Minimize a comment via GraphQL (collapses with "resolved" reason) + async function minimizeComment(commentId) { + await github.graphql(` + mutation($id: ID!) { + minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) { + minimizedComment { isMinimized } + } + } + `, { id: commentId }); + } + + // Create or update a comment; minimize the existing one if resolved + async function upsertOrResolve(marker, shouldShow, bodyLines) { + const existing = comments.find(c => c.body.includes(marker)); + if (shouldShow) { + const body = [marker, ...bodyLines].join('\n'); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + } else if (existing) { + await minimizeComment(existing.node_id); + } + } + + // --- Changeset missing --- + await upsertOrResolve('', changesetCount === 0, [ + '> [!WARNING]', + '> **No changeset found**', + '>', + '> If this PR includes user-facing changes, please add a changeset file in `.changes/`', + '', + '**Format:** `level type="kind" "description"`', + '', + '```', + 'patch type="fixed" "Fix audio frame generation"', + 'minor type="added" "Add support for custom audio processing"', + 'major type="changed" "Breaking: Rename Room.connect() to Room.join()"', + '```', + ]); + + // --- Breaking change without major changeset --- + await upsertOrResolve('', hasBreaking && !hasMajor, [ + '> [!CAUTION]', + '> **Breaking change detected without major changeset**', + '', + '`dart-apitool` detected the following breaking changes:', + '', + '```', + details, + '```', + '', + 'If this is intentional, please add a changeset with `major` level in `.changes/`:', + '```', + 'major type="changed" "Description of breaking change"', + '```', + ]);