Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -46,10 +47,103 @@ 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
with:
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
53 changes: 53 additions & 0 deletions .github/workflows/mark-vsix-stale.yml
Original file line number Diff line number Diff line change
@@ -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('<!-- vsix-size-report -->'));

if (!existing) {
core.info('No existing VSIX size comment found, nothing to mark stale.');
return;
}

// Skip if already marked stale
if (existing.body.includes('<!-- vsix-size-stale -->')) {
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<!-- vsix-size-stale -->\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.`);
228 changes: 228 additions & 0 deletions .github/workflows/post-vsix-comment.yml
Original file line number Diff line number Diff line change
@@ -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<!-- vsix-size-report -->';

// --- 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('<!-- vsix-size-report -->'));

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');
}
Loading