Skip to content

Commit 24168f8

Browse files
authored
Add PR Build Distribution System (#55)
* Add PR build installation and restoration scripts - Introduced `install-pr.ps1` and `install-pr.sh` for installing specific PR builds of the azd app extension. - Added `restore-stable.ps1` and `restore-stable.sh` for restoring the stable version of the extension. - Created documentation for PR build installation and testing in `pr-install-scripts.md` and `testing-pr-builds.md`. - Enhanced error handling and user feedback in scripts for better usability. - Updated README for scripts to include usage examples and troubleshooting tips. * fix: use gh CLI instead of azd x release for PR builds * fix: remove unnecessary cd cli command * feat: run PR build after CI workflow passes * fix: enable cleanup on PR close via pull_request_target * fix: delete existing release and tag before creating new one * fix: properly expand glob pattern for archive files * debug: add logging to find packed archives location * feat: add push trigger for prbuild branch testing * fix: use base version for archive directory path * fix: manually generate registry JSON instead of using azd x publish * fix: use branch name for script URLs in PR comments * fix: use correct registry JSON format with extensions array * fix: use azd x publish to generate PR registry * fix: post-process registry URLs to use custom PR tag * fix: mirror release flow - use azd x release and standard tag format * fix: specify artifacts path for azd x release * fix: use \C:\Users\jong instead of ~ for artifacts path * debug: add ls to see what files exist * fix: use gh release create directly instead of azd x release * fix: add GITHUB_TOKEN env to registry generation step * fix: add release deletion check before creating new one * fix: create empty registry file before azd x publish * fix: use correct tag format in install scripts and handle uninstall errors gracefully * fix: use correct hardcoded extension name in tag format * refactor: use artifacts for binaries, lightweight release for registry only - Upload all 6 platform binaries + registry as GitHub Actions artifacts (90 day retention) - Create minimal release with just registry.json for unauthenticated one-line install - Manual registry generation with correct PR version to fix version mismatch - Remove binary uploads from releases to avoid clutter - Update PR comment to reference artifacts for binaries, release for registry - Simplify cleanup job to only delete lightweight registry releases - Artifacts auto-expire after 90 days Benefits: - One-line install continues to work (downloads registry from release) - Releases page stays clean (no large binary uploads) - Full build artifacts available via Actions for manual installation - Correct PR version in registry (e.g., 0.5.7-pr55 not 0.5.7) * fix: delete existing release before creating new one * refactor: use azd x release/publish like release.yml - Use azd x release to create pre-release with all binaries - Use azd x publish to generate registry (correct format + PR version) - Upload registry to release for one-line installer - Pre-releases are public but shown separately on releases page - Revert install scripts to download from releases (no gh auth needed) - Matches standard release workflow approach * fix: copy artifacts to PR version directory for azd x release * fix: use gh CLI instead of azd x release to avoid prerelease flag issue * fix: add GH_TOKEN env var for azd x publish * fix: wait 5 seconds for release to be created before running azd x publish * fix: create empty registry file before azd x publish * fix: set EXTENSION_VERSION env var for azd x pack to use PR version * fix: temporarily modify extension.yaml to use PR version for packing * fix: properly check if release exists before deleting * fix: modify extension.yaml before azd x build to embed PR version in binaries * fix: don't restore extension.yaml until after pack * refactor: simplify PR builds to use azd x release like release.yml * fix: remove duplicate env declaration * trigger: test simplified PR build workflow * fix: use gh release create instead of azd x release (--prerelease flag broken)
1 parent 191107e commit 24168f8

16 files changed

+2899
-0
lines changed

.github/workflows/pr-build.yml

Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
1+
name: PR Build
2+
3+
on:
4+
workflow_run:
5+
workflows: ["CI"]
6+
types:
7+
- completed
8+
branches: [ main ]
9+
pull_request_target:
10+
branches: [ main ]
11+
types: [labeled, closed]
12+
push:
13+
branches: [ prbuild ]
14+
paths:
15+
- 'cli/**'
16+
- '.github/workflows/pr-build.yml'
17+
workflow_dispatch:
18+
inputs:
19+
pr_number:
20+
description: 'PR number to build'
21+
required: false
22+
type: number
23+
24+
defaults:
25+
run:
26+
working-directory: cli
27+
28+
jobs:
29+
# Check if build should run
30+
check-permission:
31+
name: Check Build Permission
32+
runs-on: ubuntu-latest
33+
# Only run if:
34+
# 1. CI workflow succeeded, OR
35+
# Only run if:
36+
# 1. CI workflow succeeded, OR
37+
# 2. Manual trigger via workflow_dispatch, OR
38+
# 3. PR labeled with 'safe-to-build', OR
39+
# 4. Push to prbuild branch (for testing)
40+
if: |
41+
github.event_name == 'workflow_dispatch' ||
42+
github.event_name == 'pull_request_target' ||
43+
github.event_name == 'push' ||
44+
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
45+
outputs:
46+
allowed: ${{ steps.check.outputs.allowed }}
47+
pr_number: ${{ steps.check.outputs.pr_number }}
48+
pr_sha: ${{ steps.check.outputs.pr_sha }}
49+
50+
steps:
51+
- name: Check if build is allowed
52+
id: check
53+
uses: actions/github-script@v7
54+
with:
55+
script: |
56+
let allowed = false;
57+
let prNumber = null;
58+
let prSha = null;
59+
60+
// Workflow dispatch - always allowed
61+
if (context.eventName === 'workflow_dispatch') {
62+
allowed = true;
63+
prNumber = context.payload.inputs.pr_number;
64+
if (prNumber) {
65+
const pr = await github.rest.pulls.get({
66+
owner: context.repo.owner,
67+
repo: context.repo.repo,
68+
pull_number: prNumber
69+
});
70+
prSha = pr.data.head.sha;
71+
}
72+
core.setOutput('allowed', 'true');
73+
core.setOutput('pr_number', prNumber || '');
74+
core.setOutput('pr_sha', prSha || '');
75+
return;
76+
}
77+
78+
// push event (for testing on prbuild branch)
79+
if (context.eventName === 'push') {
80+
allowed = true;
81+
// Find PR for this branch
82+
const { data: prs } = await github.rest.pulls.list({
83+
owner: context.repo.owner,
84+
repo: context.repo.repo,
85+
state: 'open',
86+
head: `${context.repo.owner}:${context.ref.replace('refs/heads/', '')}`
87+
});
88+
89+
if (prs.length > 0) {
90+
prNumber = prs[0].number;
91+
prSha = prs[0].head.sha;
92+
}
93+
core.setOutput('allowed', 'true');
94+
core.setOutput('pr_number', prNumber || '');
95+
core.setOutput('pr_sha', prSha || '');
96+
return;
97+
}
98+
99+
// workflow_run - triggered after CI passes
100+
if (context.eventName === 'workflow_run') {
101+
// Get the PR associated with the workflow run
102+
const { data: prs } = await github.rest.pulls.list({
103+
owner: context.repo.owner,
104+
repo: context.repo.repo,
105+
state: 'open',
106+
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`
107+
});
108+
109+
if (prs.length > 0) {
110+
const pr = prs[0];
111+
const association = pr.author_association;
112+
113+
// Check if trusted contributor
114+
const trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
115+
if (trustedAssociations.includes(association)) {
116+
allowed = true;
117+
prNumber = pr.number;
118+
prSha = pr.head.sha;
119+
} else {
120+
// For first-time contributors, check if they have the label
121+
const hasLabel = pr.labels.some(label => label.name === 'safe-to-build');
122+
if (hasLabel) {
123+
allowed = true;
124+
prNumber = pr.number;
125+
prSha = pr.head.sha;
126+
}
127+
}
128+
}
129+
}
130+
131+
// pull_request_target with 'safe-to-build' label
132+
if (context.eventName === 'pull_request_target') {
133+
const hasLabel = context.payload.pull_request.labels.some(
134+
label => label.name === 'safe-to-build'
135+
);
136+
if (hasLabel) {
137+
allowed = true;
138+
prNumber = context.payload.pull_request.number;
139+
prSha = context.payload.pull_request.head.sha;
140+
}
141+
}
142+
143+
core.setOutput('allowed', allowed ? 'true' : 'false');
144+
core.setOutput('pr_number', prNumber || '');
145+
core.setOutput('pr_sha', prSha || '');
146+
147+
if (!allowed) {
148+
core.notice('Build skipped - requires maintainer approval. Add "safe-to-build" label to proceed.');
149+
}
150+
151+
build-pr:
152+
name: Build PR Preview
153+
runs-on: ubuntu-latest
154+
needs: check-permission
155+
if: needs.check-permission.outputs.allowed == 'true'
156+
permissions:
157+
contents: write
158+
pull-requests: write
159+
160+
steps:
161+
- name: Get PR details
162+
id: pr
163+
uses: actions/github-script@v7
164+
with:
165+
script: |
166+
const prNumber = '${{ needs.check-permission.outputs.pr_number }}' || context.payload.pull_request.number;
167+
const pr = await github.rest.pulls.get({
168+
owner: context.repo.owner,
169+
repo: context.repo.repo,
170+
pull_number: prNumber
171+
});
172+
173+
core.setOutput('number', prNumber);
174+
core.setOutput('sha', pr.data.head.sha);
175+
core.setOutput('ref', pr.data.head.ref);
176+
core.setOutput('title', pr.data.title);
177+
178+
- name: Checkout code
179+
uses: actions/checkout@v4
180+
with:
181+
ref: ${{ steps.pr.outputs.sha }}
182+
183+
- name: Set up Go
184+
uses: actions/setup-go@v5
185+
with:
186+
go-version: '1.25'
187+
cache-dependency-path: cli/go.sum
188+
189+
- name: Set up Node.js
190+
uses: actions/setup-node@v4
191+
with:
192+
node-version: '20'
193+
cache: 'npm'
194+
cache-dependency-path: cli/dashboard/package.json
195+
196+
- name: Calculate PR version
197+
id: version
198+
working-directory: cli
199+
run: |
200+
BASE_VERSION=$(grep '^version:' extension.yaml | awk '{print $2}')
201+
PR_NUMBER="${{ steps.pr.outputs.number }}"
202+
PR_VERSION="${BASE_VERSION}-pr${PR_NUMBER}"
203+
# Use the standard azd extension tag format so azd x publish generates correct URLs
204+
STANDARD_TAG="azd-ext-jongio-azd-app_${PR_VERSION}"
205+
echo "version=$PR_VERSION" >> $GITHUB_OUTPUT
206+
echo "base_version=$BASE_VERSION" >> $GITHUB_OUTPUT
207+
echo "tag=$STANDARD_TAG" >> $GITHUB_OUTPUT
208+
echo "Building version: $PR_VERSION with tag: $STANDARD_TAG"
209+
210+
- name: Install azd
211+
run: curl -fsSL https://aka.ms/install-azd.sh | bash
212+
213+
- name: Install azd extensions
214+
run: azd extension install microsoft.azd.extensions
215+
216+
- name: Build dashboard
217+
working-directory: cli
218+
run: |
219+
cd dashboard
220+
npm ci
221+
npm run build
222+
223+
- name: Update extension.yaml with PR version
224+
working-directory: cli
225+
run: |
226+
VERSION="${{ steps.version.outputs.version }}"
227+
# Temporarily update version (won't commit)
228+
sed -i "s/^version: .*/version: ${VERSION}/" extension.yaml
229+
230+
- name: Build binaries
231+
working-directory: cli
232+
run: |
233+
export EXTENSION_ID="jongio.azd.app"
234+
export EXTENSION_VERSION="${{ steps.version.outputs.version }}"
235+
azd x build --all
236+
237+
- name: Package
238+
working-directory: cli
239+
run: azd x pack
240+
241+
- name: Create PR pre-release
242+
working-directory: cli
243+
env:
244+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
245+
run: |
246+
VERSION="${{ steps.version.outputs.version }}"
247+
TAG="azd-ext-jongio-azd-app_${VERSION}"
248+
PR_NUM="${{ steps.pr.outputs.number }}"
249+
250+
# Delete existing release if it exists (idempotent for re-runs)
251+
gh release delete "$TAG" --repo "${{ github.repository }}" --yes 2>/dev/null || true
252+
253+
# Create pre-release with all binaries (same pattern as release.yml but with --prerelease)
254+
gh release create "$TAG" \
255+
--repo "${{ github.repository }}" \
256+
--title "PR #${PR_NUM} Preview (v${VERSION})" \
257+
--notes "PR #${PR_NUM} preview build. Test using the one-line installer from the PR comment." \
258+
--prerelease \
259+
~/.azd/registry/jongio.azd.app/${VERSION}/*
260+
261+
- name: Update registry
262+
working-directory: cli
263+
env:
264+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
265+
run: |
266+
VERSION="${{ steps.version.outputs.version }}"
267+
268+
# Generate registry (saves to /tmp/pr-registry.json for the install scripts)
269+
echo '{"extensions":[]}' > /tmp/pr-registry.json
270+
azd x publish \
271+
--registry /tmp/pr-registry.json \
272+
--version "$VERSION" \
273+
--repo "${{ github.repository }}"
274+
275+
# Upload registry to the release so install scripts can download it
276+
TAG="azd-ext-jongio-azd-app_${VERSION}"
277+
gh release upload "$TAG" --repo "${{ github.repository }}" /tmp/pr-registry.json --clobber
278+
279+
- name: Generate installation instructions
280+
id: instructions
281+
run: |
282+
VERSION="${{ steps.version.outputs.version }}"
283+
PR_NUM="${{ steps.pr.outputs.number }}"
284+
TAG="${{ steps.version.outputs.tag }}"
285+
REPO="${{ github.repository }}"
286+
COMMIT="${{ steps.pr.outputs.sha }}"
287+
SHORT_COMMIT=$(echo $COMMIT | cut -c1-7)
288+
# Use the branch where the scripts actually exist
289+
BRANCH="${{ github.ref_name }}"
290+
291+
cat > ../instructions.md <<EOF
292+
## 🚀 Test This PR
293+
294+
A preview build (\`$VERSION\`) is ready for testing!
295+
296+
### One-Line Install (Recommended)
297+
298+
**PowerShell (Windows):**
299+
\`\`\`powershell
300+
iex "& { \$(irm https://raw.githubusercontent.com/$REPO/$BRANCH/cli/scripts/install-pr.ps1) } -PrNumber $PR_NUM -Version $VERSION"
301+
\`\`\`
302+
303+
**Bash (macOS/Linux):**
304+
\`\`\`bash
305+
curl -fsSL https://raw.githubusercontent.com/$REPO/$BRANCH/cli/scripts/install-pr.sh | bash -s $PR_NUM $VERSION
306+
\`\`\`
307+
308+
### Uninstall
309+
310+
When you're done testing:
311+
312+
**PowerShell (Windows):**
313+
\`\`\`powershell
314+
iex "& { \$(irm https://raw.githubusercontent.com/$REPO/$BRANCH/cli/scripts/uninstall-pr.ps1) } -PrNumber $PR_NUM"
315+
\`\`\`
316+
317+
**Bash (macOS/Linux):**
318+
\`\`\`bash
319+
curl -fsSL https://raw.githubusercontent.com/$REPO/$BRANCH/cli/scripts/uninstall-pr.sh | bash -s $PR_NUM
320+
\`\`\`
321+
322+
---
323+
324+
**Build Info:**
325+
- **Version:** \`$VERSION\`
326+
- **Commit:** \`$SHORT_COMMIT\`
327+
- **Release:** [View pre-release](https://github.com/$REPO/releases/tag/$TAG)
328+
329+
**What to Test:**
330+
Please review the PR description and test the changes described there.
331+
EOF
332+
333+
- name: Comment on PR
334+
uses: actions/github-script@v7
335+
with:
336+
script: |
337+
const fs = require('fs');
338+
const instructions = fs.readFileSync('instructions.md', 'utf8');
339+
const prNumber = '${{ steps.pr.outputs.number }}';
340+
341+
// Find existing comment
342+
const comments = await github.rest.issues.listComments({
343+
owner: context.repo.owner,
344+
repo: context.repo.repo,
345+
issue_number: prNumber,
346+
});
347+
348+
const botComment = comments.data.find(comment =>
349+
comment.user.type === 'Bot' &&
350+
comment.body.includes('🚀 Test This PR')
351+
);
352+
if (botComment) {
353+
// Update existing comment
354+
await github.rest.issues.updateComment({
355+
owner: context.repo.owner,
356+
repo: context.repo.repo,
357+
comment_id: botComment.id,
358+
body: instructions
359+
});
360+
console.log('Updated existing comment');
361+
} else {
362+
// Create new comment
363+
await github.rest.issues.createComment({
364+
owner: context.repo.owner,
365+
repo: context.repo.repo,
366+
issue_number: prNumber,
367+
body: instructions
368+
});
369+
console.log('Created new comment');
370+
}
371+
372+
# Cleanup job - removes registry releases when PR closes
373+
cleanup:
374+
name: Cleanup PR Registry
375+
runs-on: ubuntu-latest
376+
if: github.event.action == 'closed' && github.event_name == 'pull_request_target'
377+
permissions:
378+
contents: write
379+
380+
steps:
381+
- name: Delete PR registry release
382+
env:
383+
GH_TOKEN: ${{ github.token }}
384+
run: |
385+
PR_NUM=${{ github.event.pull_request.number }}
386+
387+
# Find and delete releases matching this PR
388+
gh release list --repo ${{ github.repository }} --json tagName,name --limit 100 | \
389+
jq -r ".[] | select(.tagName | contains(\"-pr${PR_NUM}\")) | .tagName" | \
390+
while read tag; do
391+
echo "Deleting release: $tag"
392+
gh release delete "$tag" --repo ${{ github.repository }} --yes || true
393+
# Delete tag
394+
git push --delete origin "$tag" 2>/dev/null || true
395+
done

0 commit comments

Comments
 (0)