feat(preview): publish prerelease on PR with assets (no auth downloads); update PR comment to recommend 'code preview pr:<number>'; CLI supports pr:<number> and downloads from releases; keep run-id path as fallback

This commit is contained in:
James Peter
2025-09-09 16:12:04 +10:00
parent 618c2fb7e9
commit 73de54dabe
4 changed files with 134 additions and 52 deletions
+64 -3
View File
@@ -10,7 +10,7 @@ concurrency:
cancel-in-progress: true
permissions:
contents: read
contents: write
pull-requests: write
actions: read
@@ -179,9 +179,48 @@ jobs:
path: artifacts/
compression-level: 0
release:
name: Publish prerelease (all targets)
needs: [build]
if: github.event_name == 'pull_request' && github.event.pull_request.head.ref != 'upstream-merge'
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/
- name: Create or update prerelease with assets
env:
GH_TOKEN: ${{ github.token }}
TAG: preview-pr-${{ github.event.pull_request.number }}
TITLE: Preview for PR #${{ github.event.pull_request.number }}
shell: bash
run: |
set -euo pipefail
# Show files for debugging
echo "Artifacts directory:"; ls -R artifacts || true
# Ensure release exists (create if missing)
if ! gh release view "$TAG" >/dev/null 2>&1; then
gh release create "$TAG" \
--title "$TITLE" \
--notes "Automated prerelease with preview binaries for PR #${{ github.event.pull_request.number }} (run ${{ github.run_id }})." \
--prerelease \
--latest=false \
--target "$GITHUB_SHA"
fi
# Upload/replace all artifacts
shopt -s globstar nullglob
files=(artifacts/**)
if [ ${#files[@]} -gt 0 ]; then
gh release upload "$TAG" "${files[@]}" --clobber
else
echo "No artifacts found to upload." >&2
fi
comment:
name: Post Artifact Links
needs: [build]
needs: [build, release]
runs-on: ubuntu-latest
steps:
- name: Upsert PR comment with artifact info
@@ -194,6 +233,8 @@ jobs:
const issue_number = context.payload.pull_request.number;
const runId = context.runId;
const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${runId}`;
const tag = `preview-pr-${issue_number}`;
const releaseBase = `https://github.com/${owner}/${repo}/releases/download/${tag}`;
const marker = '<!-- preview-build:artifacts -->';
@@ -201,7 +242,27 @@ jobs:
marker,
'### Preview Build',
'',
'You can run this right now! In your terminal just use:',
'Option A (no auth):',
'In your terminal use:',
'```bash',
`code preview pr:${issue_number}`,
'```
',
'This downloads from the prerelease for this PR (no GitHub token required).',
'',
'Direct downloads:',
`- Linux x86_64 (.zst): ${releaseBase}/code-x86_64-unknown-linux-musl.zst`,
`- Linux x86_64 (.tar.gz): ${releaseBase}/code-x86_64-unknown-linux-musl.tar.gz`,
`- Linux aarch64 (.zst): ${releaseBase}/code-aarch64-unknown-linux-musl.zst`,
`- Linux aarch64 (.tar.gz): ${releaseBase}/code-aarch64-unknown-linux-musl.tar.gz`,
`- macOS x86_64 (.zst): ${releaseBase}/code-x86_64-apple-darwin.zst`,
`- macOS x86_64 (.tar.gz): ${releaseBase}/code-x86_64-apple-darwin.tar.gz`,
`- macOS arm64 (.zst): ${releaseBase}/code-aarch64-apple-darwin.zst`,
`- macOS arm64 (.tar.gz): ${releaseBase}/code-aarch64-apple-darwin.tar.gz`,
`- Windows x86_64 (.zip): ${releaseBase}/code-x86_64-pc-windows-msvc.exe.zip`,
'',
'Option B:',
'You can also run by Actions run id (requires auth):',
'```bash',
`code preview ${runId}`,
'```',
+1
View File
@@ -828,6 +828,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"which",
"zip",
]
+1
View File
@@ -46,3 +46,4 @@ zip = { version = "0.6", default-features = false, features = ["deflate"] }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
tar = "0.4"
tempfile = "3"
which = "6"
+68 -49
View File
@@ -156,8 +156,8 @@ struct OrderReplayArgs {
#[derive(Debug, Parser)]
struct PreviewArgs {
/// GitHub Actions run id to download artifacts from
run_id: String,
/// Run id (e.g., 1757...), or pr:<number> to fetch from prerelease assets
ref_id: String,
/// Optional owner/repo to override (defaults to just-every/code or $GITHUB_REPOSITORY)
#[arg(long = "repo", value_name = "OWNER/REPO")]
repo: Option<String>,
@@ -384,54 +384,58 @@ async fn preview_main(args: PreviewArgs) -> anyhow::Result<()> {
_ => bail!(format!("Unsupported platform: {}/{}", os, arch)),
};
let token = env::var("GH_TOKEN").or_else(|_| env::var("GITHUB_TOKEN"))
.context("Set GH_TOKEN (or GITHUB_TOKEN) to a GitHub token with actions:read.")?;
let client = reqwest::Client::builder()
.user_agent("codex-preview/1")
.build()?;
// List artifacts for the run
let run_id = args.run_id;
let list_url = format!("https://api.github.com/repos/{owner}/{name}/actions/runs/{run_id}/artifacts?per_page=100");
let arts: serde_json::Value = client
.get(list_url)
.bearer_auth(&token)
.send()
.await?
.error_for_status()?
.json()
.await?;
let mut found_id: Option<u64> = None;
if let Some(list) = arts.get("artifacts").and_then(|a| a.as_array()) {
for a in list {
let name_v = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
if name_v == format!("preview-{}", target) {
if let Some(id) = a.get("id").and_then(|v| v.as_u64()) { found_id = Some(id); break; }
}
}
}
let art_id = found_id.context(format!("Could not find artifact preview-{target} for run {run_id}"))?;
let ref_id = args.ref_id;
let (maybe_pr, maybe_run) = if ref_id.starts_with("pr:") || ref_id.starts_with("pr-") {
(ref_id.split(|c| c == ':' || c == '-').nth(1).and_then(|s| s.parse::<u64>().ok()), None)
} else if let Ok(n) = ref_id.parse::<u64>() { (None, Some(n)) } else { (None, None) };
let zip_url = format!("https://api.github.com/repos/{owner}/{name}/actions/artifacts/{art_id}/zip");
let bytes = client
.get(zip_url)
.bearer_auth(&token)
.send()
.await?
.error_for_status()?
.bytes()
.await?;
let pr_number: u64 = if let Some(pr) = maybe_pr {
pr
} else if let Some(run_id) = maybe_run {
// Resolve run -> PR via API (requires token)
let token = env::var("GH_TOKEN").or_else(|_| env::var("GITHUB_TOKEN"))
.context("Set GH_TOKEN (or GITHUB_TOKEN) to resolve run -> PR; or pass pr:<number> to avoid auth.")?;
let run_url = format!("https://api.github.com/repos/{owner}/{name}/actions/runs/{run_id}");
let run: serde_json::Value = client.get(run_url).bearer_auth(&token).send().await?.error_for_status()?.json().await?;
// pull_requests is an array; pick first
run.get("pull_requests").and_then(|a| a.as_array()).and_then(|arr| arr.first())
.and_then(|pr| pr.get("number")).and_then(|n| n.as_u64())
.context("This run is not associated with a pull request; use pr:<number> instead.")?
} else {
bail!("Unsupported ref. Use pr:<number> or a numeric run id.");
};
let tag = format!("preview-pr-{}", pr_number);
let base = format!("https://github.com/{owner}/{name}/releases/download/{tag}");
// Try to download the best asset for this platform; prefer .tar.gz on Unix and .zip on Windows; fallback to .zst.
let mut urls: Vec<String> = vec![];
if cfg!(windows) {
urls.push(format!("{base}/code-x86_64-pc-windows-msvc.exe.zip"));
} else {
// tar.gz first, then zst
urls.push(format!("{base}/code-{target}.tar.gz"));
urls.push(format!("{base}/code-{target}.zst"));
}
let tmp = tempdir()?;
let zip_path = tmp.path().join("artifact.zip");
fs::write(&zip_path, &bytes)?;
// Unzip the artifact into tmp
let f = fs::File::open(&zip_path)?;
let mut zip = ZipArchive::new(f)?;
for i in 0..zip.len() { let _ = i; }
zip.extract(tmp.path())?;
let mut downloaded: Option<(std::path::PathBuf, String)> = None;
for u in urls.iter() {
let resp = client.get(u).send().await?;
if resp.status().is_success() {
let data = resp.bytes().await?;
let filename = u.split('/').last().unwrap_or("download.bin");
let p = tmp.path().join(filename);
fs::write(&p, &data)?;
downloaded = Some((p, u.clone()));
break;
}
}
let (path, url_used) = downloaded.context("No matching preview asset found on the prerelease. It may still be uploading; try again shortly.")?;
// Find the easiest payload
fn first_match(dir: &Path, pat: &str) -> Option<std::path::PathBuf> {
@@ -453,8 +457,9 @@ async fn preview_main(args: PreviewArgs) -> anyhow::Result<()> {
fn make_exec(_p: &Path) { }
if os != "windows" {
// Prefer .tar.gz
if let Some(tgz) = first_match(tmp.path(), "code-").and_then(|p| if p.extension().and_then(|e| e.to_str()) == Some("gz") { Some(p) } else { None }) {
// If we downloaded a tar.gz, extract
if path.extension().and_then(|e| e.to_str()) == Some("gz") {
let tgz = path.clone();
let file = fs::File::open(&tgz)?;
let gz = GzDecoder::new(file);
let mut ar = tar::Archive::new(gz);
@@ -467,9 +472,9 @@ async fn preview_main(args: PreviewArgs) -> anyhow::Result<()> {
return Ok(());
}
} else {
// Windows: expand inner code-*.zip if present
if let Some(inner_zip) = first_match(tmp.path(), "code-").and_then(|p| if p.extension().and_then(|e| e.to_str()) == Some("zip") { Some(p) } else { None }) {
let f = fs::File::open(&inner_zip)?;
// Windows: expand zip
if path.extension().and_then(|e| e.to_str()) == Some("zip") {
let f = fs::File::open(&path)?;
let mut z = ZipArchive::new(f)?;
z.extract(&out_dir)?;
let exe = first_match(&out_dir, "code-").unwrap_or(out_dir.join("code.exe"));
@@ -480,7 +485,21 @@ async fn preview_main(args: PreviewArgs) -> anyhow::Result<()> {
}
// Fallback: raw 'code' file (after .zst) if present
if let Some(bin) = first_match(tmp.path(), "code") {
if path.file_name().and_then(|s| s.to_str()).map(|n| n.ends_with(".zst")).unwrap_or(false) {
// Try to decompress .zst to 'code'
if which::which("zstd").is_ok() {
let dest = out_dir.join("code");
let status = std::process::Command::new("zstd").arg("-d").arg(&path).arg("-o").arg(&dest).status()?;
if status.success() {
make_exec(&dest);
println!("Downloaded preview from {} to {}", url_used, dest.display());
if !args.no_launch { println!("Launching: {} --help", dest.display()); let _ = std::process::Command::new(&dest).arg("--help").status(); }
return Ok(());
}
}
// If zstd missing, tell the user
bail!("Downloaded .zst but 'zstd' is not installed. Install zstd or download the .tar.gz/.zip asset instead.");
} else if let Some(bin) = first_match(tmp.path(), "code") {
let dest = out_dir.join(bin.file_name().unwrap_or_default());
fs::copy(&bin, &dest)?;
make_exec(&dest);