diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index afbd5d1..7b977e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,7 @@ on: permissions: contents: read + actions: write # delete + save the VSIX size baseline cache on push to main jobs: build-and-test: @@ -46,6 +47,17 @@ jobs: New-Item -ItemType Directory -Path artifacts -Force | Out-Null npx vsce package --no-dependencies -o artifacts/winapp-dev.vsix + # --- VSIX size metrics --- + # Measure the packaged VSIX so PRs can report the download size delta vs. + # the main baseline. The `vsix` key is stable and is the allowlist key the + # post-vsix-comment workflow renders. + - name: Measure VSIX size + run: | + $bytes = (Get-Item artifacts/winapp-dev.vsix).Length + $sizes = [ordered]@{ vsix = $bytes } + $sizes | ConvertTo-Json | Set-Content artifacts/vsix-sizes.json + Write-Host "VSIX size: $([math]::Round($bytes / 1MB, 2)) MB ($bytes bytes)" + - name: Upload VSIX artifact if: ${{ !cancelled() }} uses: actions/upload-artifact@v7 @@ -53,3 +65,85 @@ jobs: name: vscode-extension path: artifacts/*.vsix if-no-files-found: error + + # On push to main, save the current VSIX size as the baseline future PRs + # compare against. Caches are immutable, so delete the old entry first. + - name: Prepare VSIX baseline + if: github.ref == 'refs/heads/main' + run: | + New-Item -ItemType Directory -Path vsix-baseline -Force | Out-Null + Copy-Item artifacts/vsix-sizes.json vsix-baseline/sizes.json -Force + + - name: Delete old VSIX baseline cache + if: github.ref == 'refs/heads/main' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh cache delete "winappvsce-vsix-baseline" --repo $env:GITHUB_REPOSITORY 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Host "Old baseline cache deleted" + } else { + Write-Host "No existing baseline cache to delete (expected on first run)" + } + exit 0 + + - name: Save VSIX baseline cache + if: github.ref == 'refs/heads/main' + uses: actions/cache/save@v4 + with: + path: vsix-baseline + key: winappvsce-vsix-baseline + + # On PRs, restore the baseline and stage current size + baseline + PR + # context into a single `metrics-data` artifact. A separate workflow + # (post-vsix-comment.yml, triggered by workflow_run) downloads it and posts + # the comment. This split is required because PRs from forks don't grant + # GITHUB_TOKEN write access during `pull_request`, but workflow_run runs in + # the base repo with full write permissions. + - name: Restore VSIX baseline cache + if: github.event_name == 'pull_request' + id: vsix-baseline + uses: actions/cache/restore@v4 + with: + path: vsix-baseline + key: winappvsce-vsix-baseline + + - name: Stage VSIX metrics data + if: github.event_name == 'pull_request' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + RUN_ID: ${{ github.run_id }} + run: | + $stage = "metrics-data" + New-Item -ItemType Directory -Path $stage -Force | Out-Null + # Current size (produced by the Measure VSIX size step) + Copy-Item artifacts/vsix-sizes.json "$stage/vsix-sizes.json" -Force + # Baseline (restored from cache above; missing on the first-ever PR) + if (Test-Path vsix-baseline/sizes.json) { + New-Item -ItemType Directory -Path "$stage/baseline" -Force | Out-Null + Copy-Item vsix-baseline/sizes.json "$stage/baseline/sizes.json" -Force + } + # PR context (debug only — the post workflow re-derives PR identity from + # workflow_run metadata for security; never trusts these fields). + $ctx = [ordered]@{ + pr_number = [int]$env:PR_NUMBER + head_sha = $env:HEAD_SHA + run_id = [int64]$env:RUN_ID + } + $ctx | ConvertTo-Json | Set-Content "$stage/pr-context.json" + # Fail loudly if the current size file is missing so we never publish an + # empty report from the privileged post-vsix-comment workflow. + if (-not (Test-Path "$stage/vsix-sizes.json")) { + Write-Error "metrics staging is missing vsix-sizes.json. Did the Measure VSIX size step run?" + exit 1 + } + + - name: Upload metrics-data artifact + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: metrics-data + path: metrics-data + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/mark-vsix-stale.yml b/.github/workflows/mark-vsix-stale.yml new file mode 100644 index 0000000..94ffb2a --- /dev/null +++ b/.github/workflows/mark-vsix-stale.yml @@ -0,0 +1,53 @@ +name: Mark VSIX Comment Stale + +# Runs on pull_request_target so it gets write permissions even for PRs from +# forks. This workflow does NOT check out the PR's code — it only calls the +# GitHub API — so pull_request_target is safe here. + +on: + pull_request_target: + types: [opened, synchronize, reopened] + branches: [ "main" ] + +permissions: + pull-requests: write + +jobs: + mark-stale: + runs-on: ubuntu-latest + steps: + - name: Mark VSIX size comment as stale + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const allComments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + + const existing = allComments.find(c => c.body && c.body.includes('')); + + if (!existing) { + core.info('No existing VSIX size comment found, nothing to mark stale.'); + return; + } + + // Skip if already marked stale + if (existing.body.includes('')) { + core.info('Comment is already marked stale.'); + return; + } + + const staleBanner = '> :hourglass_flowing_sand: **Build in progress** — the VSIX size below is from a previous commit and will update when the current build finishes.\n>\n\n\n'; + const updatedBody = staleBanner + existing.body; + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: updatedBody, + }); + core.info(`Marked comment ${existing.id} as stale.`); diff --git a/.github/workflows/post-vsix-comment.yml b/.github/workflows/post-vsix-comment.yml new file mode 100644 index 0000000..6dc6baa --- /dev/null +++ b/.github/workflows/post-vsix-comment.yml @@ -0,0 +1,228 @@ +name: Post VSIX Size Comment + +# Triggered after the "Build and Test" workflow completes. Runs in the context +# of the *base* repository, so it has full GITHUB_TOKEN write access even when +# the source build was for a PR opened from a fork — the scenario where posting +# from inside the build job fails with 403. + +on: + workflow_run: + workflows: ["Build and Test"] + types: [completed] + +permissions: + pull-requests: write # post / update the size comment + actions: read # download the metrics-data artifact from the source run + +jobs: + post-comment: + # Only run for PR-driven builds. We intentionally do NOT gate on + # workflow_run.conclusion == 'success': the download step below is the real + # gate — it succeeds only if the source build uploaded a metrics-data + # artifact, and we skip the comment otherwise. + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Download metrics-data artifact + id: download + uses: actions/download-artifact@v4 + with: + name: metrics-data + path: metrics-data + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true + + - name: Skip if no metrics artifact + if: steps.download.outcome != 'success' + run: | + echo "::notice::Source build did not produce a metrics-data artifact (likely failed before the VSIX was packaged). Nothing to comment." + + - name: Post / update PR VSIX size comment + if: steps.download.outcome == 'success' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const dataPath = 'metrics-data'; + + // === SECURITY MODEL === + // The `metrics-data` artifact is produced by a `pull_request` build + // that ran the PR's code, so every byte in it must be treated as + // attacker-controlled. We: + // * use ONLY trusted workflow_run metadata for head_sha and the + // source run id (never the artifact's pr-context.json), + // * resolve the PR number from the GitHub API by trusted head_sha + // and verify the PR's current head still matches, + // * allowlist the single `vsix` key we render so a malicious build + // can't inject table breaks, mentions, hidden HTML, or links. + + const trustedHeadSha = context.payload.workflow_run.head_sha; + const sourceRunId = context.payload.workflow_run.id; + + // Find the PR (or PRs) whose head is trustedHeadSha. + const { data: pulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: trustedHeadSha, + }); + + // Keep only an OPEN PR whose CURRENT head still matches (skip if a + // newer commit was pushed since this build started — an older run + // shouldn't overwrite the report for a newer one). + const candidate = pulls.find(p => p.state === 'open' && p.head && p.head.sha === trustedHeadSha); + if (!candidate) { + core.info(`No open PR with current head ${trustedHeadSha}; skipping (likely a newer commit was pushed).`); + return; + } + const prNumber = candidate.number; + + // === Whitelisted metric keys === + const ALLOWED_SIZE_KEYS = new Set(['vsix']); + const labels = { + 'vsix': 'VS Code Extension (VSIX)', + }; + + // safeNum: coerce to a finite non-negative number or 0 + function safeNum(v) { + const n = Number(v); + return Number.isFinite(n) && n >= 0 ? n : 0; + } + + // sanitizeSizes: drop unknown keys, coerce values + function sanitizeSizes(obj) { + const out = {}; + if (obj && typeof obj === 'object') { + for (const [k, v] of Object.entries(obj)) { + if (ALLOWED_SIZE_KEYS.has(k)) out[k] = safeNum(v); + } + } + return out; + } + + // --- Load + sanitize current size --- + let current = {}; + try { current = sanitizeSizes(JSON.parse(fs.readFileSync(`${dataPath}/vsix-sizes.json`, 'utf8'))); } catch {} + + // --- Load + sanitize baseline (may be missing on the first-ever PR) --- + let sizeBaseline = {}; + let hasBaseline = false; + try { + sizeBaseline = sanitizeSizes(JSON.parse(fs.readFileSync(`${dataPath}/baseline/sizes.json`, 'utf8'))); + hasBaseline = Object.keys(sizeBaseline).length > 0; + } catch {} + + // --- Artifact download link (resolved against the SOURCE run) --- + const artifactLinks = {}; + try { + const { data: { artifacts } } = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: sourceRunId, + }); + const artifactNameMap = { + 'vsix': 'vscode-extension', + }; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${sourceRunId}`; + for (const [sizeKey, artifactName] of Object.entries(artifactNameMap)) { + const artifact = artifacts.find(a => a.name === artifactName); + if (artifact) { + artifactLinks[sizeKey] = `${runUrl}/artifacts/${artifact.id}`; + } + } + } catch (e) { + core.warning(`Could not fetch artifact links: ${e.message}`); + } + + // --- Helpers --- + function formatBytes(bytes) { + const abs = Math.abs(bytes); + const mb = abs / (1024 * 1024); + if (mb >= 1) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + const kb = bytes / 1024; + return `${kb.toFixed(1)} KB`; + } + + function formatDelta(curr, base) { + const delta = curr - base; + const pct = base > 0 ? ((delta / base) * 100).toFixed(2) : 'N/A'; + const sign = delta > 0 ? '+' : ''; + let icon = ':white_check_mark:'; + if (delta > 0) icon = ':chart_with_upwards_trend:'; + else if (delta < 0) icon = ':chart_with_downwards_trend:'; + return `${icon} ${sign}${formatBytes(delta)} (${sign}${pct}%)`; + } + + function artifactLink(key, label) { + const url = artifactLinks[key]; + return url ? `[${label}](${url})` : label; + } + + // --- Build report body --- + // (keys are already whitelisted via sanitizeSizes; labels[key] is always defined) + let body = '## VSIX Build\n\n'; + if (hasBaseline) { + body += '| Artifact | Baseline | Current | Delta |\n'; + body += '|----------|----------|---------|-------|\n'; + const allKeys = [...new Set([...Object.keys(sizeBaseline), ...Object.keys(current)])].sort(); + for (const key of allKeys) { + const label = labels[key]; + if (!label) continue; + const linkedLabel = artifactLink(key, label); + const baseSize = sizeBaseline[key] || 0; + const currSize = current[key] || 0; + if (currSize === 0 && baseSize === 0) continue; + const baseStr = baseSize > 0 ? formatBytes(baseSize) : 'N/A'; + const currStr = currSize > 0 ? formatBytes(currSize) : 'N/A'; + const deltaStr = (baseSize > 0 && currSize > 0) + ? formatDelta(currSize, baseSize) : 'N/A'; + body += `| ${linkedLabel} | ${baseStr} | ${currStr} | ${deltaStr} |\n`; + } + } else { + body += '> No baseline found. Showing absolute size only.\n\n'; + body += '| Artifact | Size |\n'; + body += '|----------|------|\n'; + for (const [key, size] of Object.entries(current).sort()) { + const label = labels[key]; + if (!label) continue; + const linkedLabel = artifactLink(key, label); + body += `| ${linkedLabel} | ${formatBytes(size)} |\n`; + } + } + + // --- Footer (uses TRUSTED head_sha + source run id) --- + const timestamp = new Date().toISOString().replace('T', ' ').replace(/\.[0-9]+Z$/, ' UTC'); + const shortSha = trustedHeadSha.substring(0, 7); + const commitUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/commit/${trustedHeadSha}`; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${sourceRunId}`; + body += `\n---\n*Updated ${timestamp} · commit [\`${shortSha}\`](${commitUrl}) · [workflow run](${runUrl})*\n`; + body += '\n'; + + // --- Find existing comment (paginated) and update; otherwise create --- + const allComments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + }); + + const existing = allComments.find(c => c.body && c.body.includes('')); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + core.info(`Updated existing comment ${existing.id}`); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + core.info('Created new VSIX size report comment'); + }