Skip to content

Nightly: Bootstrap Extensions Check #254

Nightly: Bootstrap Extensions Check

Nightly: Bootstrap Extensions Check #254

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"