Nightly: Bootstrap Extensions Check #254
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: 'Nightly: Bootstrap Extensions Check' | |
| # Run builds daily at 2am UTC (10p EST) on weekdays for now, or manually | |
| on: | |
| schedule: | |
| - cron: "0 2 * * 1-5" | |
| workflow_dispatch: | |
| inputs: | |
| dryrun: | |
| description: 'Dry run: detect mismatches and run the bump script, but skip commit/push/PR/Slack.' | |
| type: boolean | |
| default: false | |
| permissions: | |
| id-token: write | |
| contents: read | |
| packages: read | |
| jobs: | |
| extensions-linux: | |
| name: 'extensions-linux' | |
| timeout-minutes: 60 | |
| runs-on: ubuntu-latest-8x | |
| container: | |
| image: ghcr.io/posit-dev/positron-ubuntu24-amd64:141 | |
| options: --user 0:0 | |
| # Static PAT is needed because the bot can't pass a token to the job for security reasons | |
| credentials: | |
| username: ${{ secrets.POSITRON_GITHUB_RO_USER }} | |
| password: ${{ secrets.POSITRON_GITHUB_RO_PAT }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| POSITRON_BUILD_NUMBER: 0 # CI skips building releases | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| submodules: recursive | |
| - name: Setup build environment | |
| uses: ./.github/actions/setup-build-env | |
| with: | |
| distro: ubuntu | |
| install-playwright: 'true' | |
| github-token: ${{ github.token }} | |
| - name: Setup Xvfb | |
| uses: ./.github/actions/setup-xvfb | |
| - name: Send Results to GH Summary | |
| uses: ./.github/actions/gen-report-dir | |
| with: | |
| identifier: e2e-electron | |
| - name: 🧪 Run Playwright Tests (Electron) | |
| shell: bash | |
| env: | |
| POSITRON_PY_VER_SEL: "3.10.12" | |
| POSITRON_R_VER_SEL: 4.5.2 | |
| POSITRON_PY_ALT_VER_SEL: "3.13.0" | |
| POSITRON_R_ALT_VER_SEL: 4.4.2 | |
| PWTEST_BLOB_DO_NOT_REMOVE: 1 | |
| EXTENSIONS_FAIL_ON_MISMATCH: true | |
| run: | | |
| echo "Running: npx playwright test test/e2e/tests/extensions/bootstrap-extensions.test.ts --project e2e-electron --reporter=null" | |
| npx playwright test test/e2e/tests/extensions/bootstrap-extensions.test.ts --project e2e-electron --reporter=null | |
| - name: Upload mismatched extensions list | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: mismatched-extensions | |
| path: test-logs/mismatched-extensions.txt | |
| retention-days: 3 | |
| if-no-files-found: ignore | |
| auto-update: | |
| name: 'auto-update-extensions' | |
| if: always() && needs.extensions-linux.result != 'cancelled' | |
| needs: [extensions-linux] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| outputs: | |
| pr_url: ${{ steps.create-pr.outputs.pr_url || steps.detect.outputs.pr_url }} | |
| steps: | |
| - name: Generate POSITRON_BOT token | |
| id: bot-token | |
| uses: actions/create-github-app-token@v3 | |
| with: | |
| app-id: ${{ vars.POSITRON_BOT_APP_ID }} | |
| private-key: ${{ secrets.POSITRON_BOT_PRIVATE_KEY }} | |
| owner: ${{ github.repository_owner }} | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| token: ${{ steps.bot-token.outputs.token }} | |
| - name: Configure git identity | |
| run: | | |
| git config user.name "positron-bot[bot]" | |
| git config user.email "positron-bot[bot]@users.noreply.github.com" | |
| - name: Detect existing auto-update PR | |
| id: detect | |
| env: | |
| GH_TOKEN: ${{ steps.bot-token.outputs.token }} | |
| run: | | |
| # Find an open auto-update PR (branch prefix matches our convention). | |
| PR_INFO=$(gh pr list --base main --state open \ | |
| --json number,url,headRefName,reviewDecision \ | |
| --jq '[.[] | select(.headRefName | startswith("automated/update-extensions"))] | first // empty') | |
| if [ -z "$PR_INFO" ]; then | |
| echo "No existing auto-update PR found; a new one will be created if needed." | |
| echo "exists=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| PR_NUMBER=$(echo "$PR_INFO" | jq -r .number) | |
| PR_URL=$(echo "$PR_INFO" | jq -r .url) | |
| PR_BRANCH=$(echo "$PR_INFO" | jq -r .headRefName) | |
| PR_REVIEW=$(echo "$PR_INFO" | jq -r '.reviewDecision // ""') | |
| echo "Found existing PR #$PR_NUMBER on branch $PR_BRANCH (review: ${PR_REVIEW:-none})" | |
| # Treat any human review attention as a signal to add commits rather than force-push. | |
| # /pulls/.../comments covers inline review-thread comments; /issues/.../comments | |
| # covers the main conversation tab — both must be checked, and bot accounts | |
| # (positron-bot, github-actions, etc.) are filtered out so routine automation | |
| # doesn't trip the safeguard. | |
| BOT_FILTER='[.[] | select((.user.type // "User") != "Bot")] | length' | |
| REVIEW_COMMENTS=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUMBER/comments" --jq "$BOT_FILTER") | |
| ISSUE_COMMENTS=$(gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments" --jq "$BOT_FILTER") | |
| NON_BOT_COMMITS=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUMBER/commits" \ | |
| --jq '[.[] | select((.author.type // "User") != "Bot")] | length') | |
| if [ "$REVIEW_COMMENTS" -gt 0 ] || [ "$ISSUE_COMMENTS" -gt 0 ] || [ "$NON_BOT_COMMITS" -gt 0 ] || [ -n "$PR_REVIEW" ]; then | |
| MODE=additive | |
| else | |
| MODE=reset | |
| fi | |
| echo "Reuse mode: $MODE (review comments: $REVIEW_COMMENTS, issue comments: $ISSUE_COMMENTS, non-bot commits: $NON_BOT_COMMITS, review decision: ${PR_REVIEW:-none})" | |
| { | |
| echo "exists=true" | |
| echo "pr_number=$PR_NUMBER" | |
| echo "pr_url=$PR_URL" | |
| echo "pr_branch=$PR_BRANCH" | |
| echo "mode=$MODE" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Prepare branch and capture pre-update state | |
| id: prepare | |
| if: steps.detect.outputs.exists == 'true' | |
| env: | |
| PR_BRANCH: ${{ steps.detect.outputs.pr_branch }} | |
| run: | | |
| git fetch origin "$PR_BRANCH" main | |
| git checkout "$PR_BRANCH" | |
| # Capture the extension set already on the PR (before any reset/reapply). | |
| # Drives the Slack "new extensions" trigger downstream. | |
| PRE_SET=$(jq -rn \ | |
| --argjson old "$(git show origin/main:product.json)" \ | |
| --argjson new "$(cat product.json)" \ | |
| '($old.bootstrapExtensions | map({(.name): .version}) | add) as $ov | | |
| ($new.bootstrapExtensions | map({(.name): .version}) | add) as $nv | | |
| [$nv | to_entries[] | select(.value != $ov[.key]) | .key] | sort | join(" ")') | |
| echo "Pre-update extension set: ${PRE_SET:-<empty>}" | |
| echo "pre_set=$PRE_SET" >> "$GITHUB_OUTPUT" | |
| - name: Reset branch to main (clean reapply) | |
| if: steps.detect.outputs.exists == 'true' && steps.detect.outputs.mode == 'reset' | |
| env: | |
| PR_BRANCH: ${{ steps.detect.outputs.pr_branch }} | |
| run: | | |
| echo "Resetting $PR_BRANCH to origin/main; the bump script will reapply today's changes from scratch." | |
| git reset --hard origin/main | |
| - name: Download mismatched extensions list | |
| if: needs.extensions-linux.result == 'failure' | |
| continue-on-error: true | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: mismatched-extensions | |
| path: . | |
| - name: Update extensions | |
| env: | |
| TEST_RESULT: ${{ needs.extensions-linux.result }} | |
| run: | | |
| MISMATCHED=() | |
| if [ "$TEST_RESULT" = "failure" ] && [ -s mismatched-extensions.txt ]; then | |
| read -ra MISMATCHED <<< "$(cat mismatched-extensions.txt)" | |
| fi | |
| # meta.pyrefly is optional in the test, so bump it every run to catch silent drift. | |
| EXTENSIONS_TO_BUMP=(meta.pyrefly "${MISMATCHED[@]}") | |
| echo "Bumping extensions: ${EXTENSIONS_TO_BUMP[*]}" | |
| { | |
| echo "### Bootstrap extensions to bump" | |
| echo "" | |
| printf -- '- %s\n' "${EXTENSIONS_TO_BUMP[@]}" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| ./scripts/update-extensions.sh "${EXTENSIONS_TO_BUMP[@]}" | |
| - name: Show product.json diff | |
| run: | | |
| if git diff --quiet product.json; then | |
| echo "No changes to product.json." | |
| else | |
| git --no-pager diff product.json | |
| fi | |
| - name: Render test tags | |
| id: render-tags | |
| env: | |
| PRE_SET: ${{ steps.prepare.outputs.pre_set }} | |
| run: | | |
| # Compare against origin/main so reused PRs report the *full* bump set, | |
| # not just today's incremental change on top of an existing commit. | |
| git fetch origin main | |
| UPDATED=$(jq -rn \ | |
| --argjson old "$(git show origin/main:product.json)" \ | |
| --argjson new "$(cat product.json)" \ | |
| '($old.bootstrapExtensions | map({(.name): .version}) | add) as $ov | | |
| ($new.bootstrapExtensions | map({(.name): .version}) | add) as $nv | | |
| [$nv | to_entries[] | select(.value != $ov[.key]) | .key] | sort | join(" ")') | |
| if [ -z "$UPDATED" ]; then | |
| echo "No bootstrap extension version changes detected." | |
| echo "updated=" >> "$GITHUB_OUTPUT" | |
| echo "new_exts=" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Extensions newly added on top of the existing PR (empty for a brand-new PR). | |
| if [ -z "$PRE_SET" ]; then | |
| NEW_EXTS="$UPDATED" | |
| else | |
| NEW_EXTS=$(comm -23 \ | |
| <(printf '%s\n' $UPDATED | sort -u) \ | |
| <(printf '%s\n' $PRE_SET | sort -u) \ | |
| | tr '\n' ' ' | sed 's/ *$//') | |
| fi | |
| echo "new_exts=$NEW_EXTS" >> "$GITHUB_OUTPUT" | |
| TAG_MAP=.github/workflows/extensions-tag-map.json | |
| TAG_BULLETS=$(echo "$UPDATED" | tr ' ' '\n' | jq -R -s -r --slurpfile map "$TAG_MAP" ' | |
| (split("\n") | map(select(length > 0))) as $exts | | |
| $map[0] as $m | | |
| $exts | map( | |
| . as $e | $m[$e] as $entry | | |
| if $entry == null then | |
| "- " + $e + " -> (unmapped; add to extensions-tag-map.json)" | |
| elif ($entry.tags | length) > 0 then | |
| "- " + $e + " -> " + ($entry.tags | join(", ")) | |
| else | |
| "- " + $e + " -> n/a" | |
| end | |
| ) | join("\n") | |
| ') | |
| COMBINED_TAGS=$(echo "$UPDATED" | tr ' ' '\n' | jq -R -s -r --slurpfile map "$TAG_MAP" ' | |
| split("\n") | map(select(length > 0)) | map($map[0][.].tags // []) | flatten | unique | join(" ") | |
| ') | |
| UNMAPPED=$(echo "$UPDATED" | tr ' ' '\n' | jq -R -s -r --slurpfile map "$TAG_MAP" ' | |
| split("\n") | map(select(length > 0)) | map(select($map[0][.] == null)) | join(", ") | |
| ') | |
| { | |
| echo "### Suggested test tags" | |
| echo "" | |
| echo "$TAG_BULLETS" | |
| if [ -n "$COMBINED_TAGS" ]; then | |
| echo "" | |
| echo "Combined: $COMBINED_TAGS" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| echo "updated=$UPDATED" >> "$GITHUB_OUTPUT" | |
| echo "combined_tags=$COMBINED_TAGS" >> "$GITHUB_OUTPUT" | |
| echo "unmapped=$UNMAPPED" >> "$GITHUB_OUTPUT" | |
| - name: Commit and push | |
| id: push | |
| env: | |
| GH_TOKEN: ${{ steps.bot-token.outputs.token }} | |
| EXISTS: ${{ steps.detect.outputs.exists }} | |
| MODE: ${{ steps.detect.outputs.mode }} | |
| PR_BRANCH: ${{ steps.detect.outputs.pr_branch }} | |
| DRYRUN: ${{ inputs.dryrun }} | |
| run: | | |
| if [ "$EXISTS" = "true" ]; then | |
| BRANCH="$PR_BRANCH" | |
| git add product.json | |
| if git diff --staged --quiet && git diff --quiet origin/main -- product.json; then | |
| # Reset-mode + no bumps needed: working tree matches origin/main and the | |
| # existing PR's commits on the remote are untouched. We intentionally don't | |
| # set branch/reused outputs here — skipping create-pr means the existing | |
| # title/body stay as-is, which still accurately describe the PR's content. | |
| # (Refreshing now would render an empty extension list, since UPDATED is | |
| # computed from working tree vs origin/main and is empty in this path.) | |
| echo "Reset produced no diff vs main — existing PR untouched." | |
| exit 0 | |
| fi | |
| if git diff --staged --quiet; then | |
| echo "No working-tree changes after update; existing PR commits already cover it." | |
| echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" | |
| echo "reused=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if [ "$DRYRUN" = "true" ]; then | |
| if [ "$MODE" = "reset" ]; then | |
| echo "[dryrun] Would commit and force-push to existing branch $BRANCH (reset mode)." | |
| else | |
| echo "[dryrun] Would commit and push to existing branch $BRANCH (additive mode)." | |
| fi | |
| else | |
| git commit -m "chore: update bootstrap extensions" | |
| if [ "$MODE" = "reset" ]; then | |
| git push --force-with-lease origin "$BRANCH" | |
| else | |
| git push origin "$BRANCH" | |
| fi | |
| fi | |
| echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" | |
| echo "reused=true" >> "$GITHUB_OUTPUT" | |
| else | |
| BRANCH="automated/update-extensions-$(date +%Y%m%d-%H%M%S)" | |
| git checkout -b "$BRANCH" | |
| git add product.json | |
| if git diff --staged --quiet; then | |
| echo "No changes to product.json — skipping PR" | |
| exit 0 | |
| fi | |
| if [ "$DRYRUN" = "true" ]; then | |
| echo "[dryrun] Would commit and push new branch $BRANCH." | |
| else | |
| git commit -m "chore: update bootstrap extensions" | |
| git push origin "$BRANCH" | |
| fi | |
| echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" | |
| echo "reused=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Create or update pull request | |
| id: create-pr | |
| if: steps.push.outputs.branch != '' | |
| env: | |
| GH_TOKEN: ${{ steps.bot-token.outputs.token }} | |
| BRANCH: ${{ steps.push.outputs.branch }} | |
| REUSED: ${{ steps.push.outputs.reused }} | |
| EXISTING_PR_NUMBER: ${{ steps.detect.outputs.pr_number }} | |
| EXISTING_PR_URL: ${{ steps.detect.outputs.pr_url }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| MAP_URL: ${{ github.server_url }}/${{ github.repository }}/blob/main/.github/workflows/extensions-tag-map.json | |
| UPDATED: ${{ steps.render-tags.outputs.updated }} | |
| COMBINED_TAGS: ${{ steps.render-tags.outputs.combined_tags }} | |
| UNMAPPED: ${{ steps.render-tags.outputs.unmapped }} | |
| DRYRUN: ${{ inputs.dryrun }} | |
| run: | | |
| EXT_BULLETS=$(echo "$UPDATED" | tr ' ' '\n' | sed 's/^/- /') | |
| # Truncate title if more than 3 extensions to stay within GitHub's limit | |
| EXT_ARRAY=($UPDATED) | |
| COUNT=${#EXT_ARRAY[@]} | |
| if [ "$COUNT" -le 3 ]; then | |
| TITLE="chore: update bootstrap extensions ($(echo "$UPDATED" | tr ' ' ','))" | |
| else | |
| FIRST3="${EXT_ARRAY[0]},${EXT_ARRAY[1]},${EXT_ARRAY[2]}" | |
| TITLE="chore: update bootstrap extensions ($FIRST3 +$((COUNT - 3)) more)" | |
| fi | |
| TAGS_LINE="" | |
| if [ -n "$COMBINED_TAGS" ]; then | |
| TAGS_LINE="${COMBINED_TAGS} | |
| " | |
| fi | |
| UNMAPPED_LINE="" | |
| if [ -n "$UNMAPPED" ]; then | |
| UNMAPPED_LINE="[Unmapped](${MAP_URL}): ${UNMAPPED} | |
| " | |
| fi | |
| BODY="### Summary | |
| Update bootstrap extensions detected as out-of-date by the nightly check. | |
| ${EXT_BULLETS} | |
| ### QA Notes | |
| ${TAGS_LINE}${UNMAPPED_LINE}Triggered by [nightly extensions check](${RUN_URL}). | |
| _This PR is auto-managed by the nightly bootstrap-extensions workflow; the title and body may be overwritten on subsequent runs._" | |
| if [ "$DRYRUN" = "true" ]; then | |
| if [ "$REUSED" = "true" ]; then | |
| echo "[dryrun] Would update existing PR #$EXISTING_PR_NUMBER ($EXISTING_PR_URL)" | |
| PR_URL="$EXISTING_PR_URL" | |
| else | |
| echo "[dryrun] Would create new PR from branch $BRANCH" | |
| PR_URL="https://example.invalid/dryrun" | |
| fi | |
| echo "[dryrun] Title: $TITLE" | |
| echo "[dryrun] Body:" | |
| printf '%s\n' "$BODY" | sed 's/^/[dryrun] /' | |
| elif [ "$REUSED" = "true" ]; then | |
| gh pr edit "$EXISTING_PR_NUMBER" --title "$TITLE" --body "$BODY" >/dev/null | |
| PR_URL="$EXISTING_PR_URL" | |
| echo "Updated existing PR: $PR_URL" | |
| else | |
| PR_URL=$(gh pr create \ | |
| --base main \ | |
| --head "$BRANCH" \ | |
| --title "$TITLE" \ | |
| --body "$BODY") | |
| echo "Created new PR: $PR_URL" | |
| fi | |
| echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" | |
| echo "updated=$UPDATED" >> "$GITHUB_OUTPUT" | |
| - name: Post PR to Slack | |
| if: steps.create-pr.outputs.pr_url != '' | |
| env: | |
| SLACK_TOKEN: ${{ secrets.SLACK_TOKEN_TEST_STATUS }} | |
| PR_URL: ${{ steps.create-pr.outputs.pr_url }} | |
| UPDATED: ${{ steps.create-pr.outputs.updated }} | |
| NEW_EXTS: ${{ steps.render-tags.outputs.new_exts }} | |
| REUSED: ${{ steps.push.outputs.reused }} | |
| DRYRUN: ${{ inputs.dryrun }} | |
| run: | | |
| # Skip the repost when reusing a PR that didn't gain any new extensions today. | |
| if [ "$REUSED" = "true" ] && [ -z "$NEW_EXTS" ]; then | |
| echo "Reused existing PR with no new extensions — skipping Slack repost." | |
| exit 0 | |
| fi | |
| # Slack bullets reflect today's contribution: the newly added subset for reuse, | |
| # or the full set for a brand-new PR. | |
| BULLET_EXTS="${NEW_EXTS:-$UPDATED}" | |
| PAYLOAD=$(jq -n \ | |
| --arg channel "#positron-dev" \ | |
| --arg pr_url "$PR_URL" \ | |
| --arg ext "$BULLET_EXTS" \ | |
| '($pr_url | split("/") | last) as $pr_num | | |
| (if $ext != "" then "\n\n" + ($ext | split(" ") | map("• " + .) | join("\n")) else "" end) as $bullets | | |
| { | |
| channel: $channel, | |
| text: ("📦 *Bootstrap Extensions Updated* · <" + $pr_url + "|PR #" + $pr_num + ">" + $bullets) | |
| }') | |
| if [ "$DRYRUN" = "true" ]; then | |
| echo "[dryrun] Would post to Slack:" | |
| printf '%s\n' "$PAYLOAD" | sed 's/^/[dryrun] /' | |
| exit 0 | |
| fi | |
| curl -s -X POST https://slack.com/api/chat.postMessage \ | |
| -H "Authorization: Bearer $SLACK_TOKEN" \ | |
| -H "Content-Type: application/json" \ | |
| -d "$PAYLOAD" | |
| slack-notify: | |
| if: always() && needs.extensions-linux.result == 'failure' && needs.auto-update.outputs.pr_url == '' && inputs.dryrun != true | |
| needs: [extensions-linux, auto-update] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Notify Slack | |
| uses: midleman/slack-workflow-status@v3.1.3 | |
| with: | |
| gh_repo_token: ${{ secrets.GITHUB_TOKEN }} | |
| slack_token: ${{ secrets.SLACK_TOKEN_TEST_STATUS }} | |
| slack_channel: "#positron-test-results" | |
| notify_on: "failure" |