Skip to content
Draft
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
19 changes: 0 additions & 19 deletions .github/scripts/build/build-npm-packages.sh

This file was deleted.

47 changes: 0 additions & 47 deletions .github/scripts/build/derive-dist-dry.sh

This file was deleted.

63 changes: 63 additions & 0 deletions .github/scripts/build/download-release-archives.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// @ts-check

import { mkdir, readdir, rm } from "node:fs/promises";
import { env } from "node:process";

/** @typedef {import('@actions/github-script').AsyncFunctionArguments['core']} Core */

const DOWNLOADS_DIR = "npm/downloads";

/**
* Download every `runner-<tag>-<target>.tar.gz` + `.sha256` pair from the
* (still draft) GitHub release into `npm/downloads`.
*
* Uses the `gh` CLI rather than the REST API because the release is a draft
* at this point in the pipeline — `getReleaseByTag` can't see drafts, while
* `gh release download` resolves them fine with a `contents: write` token.
*
* Required env:
* - `RELEASE_TAG` — git tag, e.g. `v0.6.0`.
* - `GH_TOKEN` — token with `contents: write` on this repo.
*
* @param {Pick<import('@actions/github-script').AsyncFunctionArguments, 'core' | 'context' | 'exec'>} args
*/
export default async function downloadReleaseArchives({ core, context, exec }) {
const releaseTag = env.RELEASE_TAG;
if (!releaseTag) {
fail(core, "RELEASE_TAG env var is required");
return;
}

// Scrub before fetch: stale .tar.gz/.sha256 from a previous tag would pass
// verify-checksum.mjs (which walks every file in this dir) but be
// wrong-version for the current RELEASE_TAG. Hosted GHA runners get fresh
// workspaces, but self-hosted runners and local invocations don't.
await rm(DOWNLOADS_DIR, { recursive: true, force: true });
await mkdir(DOWNLOADS_DIR, { recursive: true });

await exec.exec("gh", [
"release",
"download",
releaseTag,
"--repo",
`${context.repo.owner}/${context.repo.repo}`,
"--pattern",
"runner-*-*.tar.gz",
"--pattern",
"runner-*-*.sha256",
"--dir",
DOWNLOADS_DIR,
]);

const files = await readdir(DOWNLOADS_DIR);
core.info(`Downloaded ${files.length} files:\n${files.sort().join("\n")}`);
}

/**
* @param {Core} core
* @param {string} message
*/
function fail(core, message) {
core.error(message, { file: ".github/workflows/release.yml", title: "Release archive download failed" });
core.setFailed(message);
}
19 changes: 0 additions & 19 deletions .github/scripts/build/download-release-archives.sh

This file was deleted.

46 changes: 46 additions & 0 deletions .github/scripts/build/man-archive.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// @ts-check

import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { env } from "node:process";

/** @typedef {import('@actions/github-script').AsyncFunctionArguments['core']} Core */

/**
* Archive the generated `man/` directory and upload the tarball plus its
* `.sha256` companion to the GitHub release for the current tag.
*
* Required env:
* - `RELEASE_TAG` — git tag, e.g. `v0.6.0`.
* - `GH_TOKEN` — token with `contents: write` on this repo.
*
* @param {Pick<import('@actions/github-script').AsyncFunctionArguments, 'core' | 'exec'>} args
*/
export default async function archiveManPages({ core, exec }) {
const releaseTag = env.RELEASE_TAG;
if (!releaseTag) {
fail(core, "RELEASE_TAG env var is required");
return;
}
Comment on lines +20 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Arrr! Both scripts be vulnerable to the same path traversal scurvy! 🏴‍☠️

Both man-archive.mjs and package-release-asset.mjs accept RELEASE_TAG (and package-release-asset.mjs also accepts TARGET) from environment variables and immediately use them in filename construction without validation. A malicious tag like ../../etc/passwd or v1.0/../evil could write files outside the intended directory. Apply consistent semver validation to RELEASE_TAG in both scripts, and validate that TARGET contains no path separators.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/build/man-archive.mjs around lines 20 - 24, Validate and
sanitize environment inputs before using them to build filenames: check
env.RELEASE_TAG (releaseTag) with a strict semver validator (e.g.
semver.valid(...) or an equivalent regex) and call fail(core, "...") if invalid,
and for env.TARGET ensure it contains no path separators (no '/' or '\\') before
using it; apply the same validation pattern to package-release-asset.mjs as well
so both scripts reject traversal characters and only accept safe, validated
values.


const archive = `runner-${releaseTag}-man.tar.gz`;
const checksum = `runner-${releaseTag}-man.sha256`;

await exec.exec("tar", ["-C", "man", "-czf", archive, "."]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Arrr, this tar command be lookin' mighty suspicious! 🏴‍☠️

Ye be usin' tar -C man -czf with a hardcoded "." as the source, but there be no check that the man/ directory actually exists before running tar! If the directory be missing, tar will fail with a cryptic error instead of yer nice fail() message.

🗺️ Defensive check for man/ directory
+	import { access } from "node:fs/promises";
+	import { constants } from "node:fs";
+
 	const archive = `runner-${releaseTag}-man.tar.gz`;
 	const checksum = `runner-${releaseTag}-man.sha256`;
 
+	try {
+		await access("man", constants.R_OK);
+	} catch {
+		fail(core, "man/ directory not found — man pages must be built first");
+		return;
+	}
+
 	await exec.exec("tar", ["-C", "man", "-czf", archive, "."]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/build/man-archive.mjs at line 29, The tar invocation using
exec.exec("tar", ["-C", "man", "-czf", archive, "."]) runs without ensuring the
man/ directory exists; add a defensive check (e.g., fs.existsSync or
fs.promises.stat) for the "man" directory before calling exec.exec and call
fail(...) with a clear error message if it is missing so tar doesn't produce a
cryptic error. Locate the call to exec.exec in
.github/scripts/build/man-archive.mjs and gate it behind the existence check
referencing the same archive variable and preserve existing error handling flow.


// `<hex> <basename>`, the format `sha256sum` emits — aur.mjs and any
// downstream consumer parse the first whitespace-separated field.
const digest = createHash("sha256").update(await readFile(archive)).digest("hex");
await writeFile(checksum, `${digest} ${archive}\n`, "utf8");

await exec.exec("gh", ["release", "upload", releaseTag, archive, checksum, "--clobber"]);
}

/**
* @param {Core} core
* @param {string} message
*/
function fail(core, message) {
core.error(message, { file: ".github/workflows/release.yml", title: "Man page archive failed" });
core.setFailed(message);
}
42 changes: 42 additions & 0 deletions .github/scripts/build/npm.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// @ts-check

import { access } from "node:fs/promises";

const TAG_RE = /^v(?<version>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/;

/**
* @param {Pick<import('@actions/github-script').AsyncFunctionArguments, 'core' | 'context' | 'exec'>} args
*/
export default async function buildNpmPackages({ core, context, exec }) {
const tag = context.ref.replace(/^refs\/tags\//, "");
const version = TAG_RE.exec(tag)?.groups?.version;
if (version === undefined) {
core.error(`invalid tag: ${tag}`, {
file: ".github/workflows/release.yml",
title: "Invalid release tag",
});
core.setFailed("invalid release tag");
return;
}

const args = ["npm/scripts/build-packages.ts", "--version", version];
if (await exists("man")) args.push("--man-dir", "man");
await exec.exec("node", args);
await core.summary
.addHeading("npm package build")
.addRaw(`Built npm packages for ${tag}.`)
.write();
}

/**
* @param {string} path
* @returns {Promise<boolean>}
*/
async function exists(path) {
try {
await access(path);
return true;
} catch {
return false;
}
}
104 changes: 104 additions & 0 deletions .github/scripts/build/package-release-asset.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// @ts-check

import { createHash } from "node:crypto";
import { chmod, copyFile, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { env } from "node:process";

/** @typedef {import('@actions/github-script').AsyncFunctionArguments['core']} Core */

/**
* Package built `runner` and `run` binaries into a release tarball matching
* the layout `taiki-e/upload-rust-binary-action` produces, then upload the
* archive and its `.sha256` companion to the GitHub release for the current
* tag.
*
* Used by release.yml for build paths that can't go through
* `taiki-e/upload-rust-binary-action`:
*
* - tier-3 BSD targets requiring `-Z build-std` (the action has no way to
* inject that flag), and
* - VM-built targets such as OpenBSD (the action runs on the outer Linux
* host and never sees the VM's filesystem).
*
* Required env:
* - `RELEASE_TAG` — git tag, e.g. `v0.6.0`. Same value the matrix consumes.
* - `TARGET` — Rust target triple, e.g. `aarch64-unknown-freebsd`.
* - `BIN_DIR` — directory containing the freshly built `runner` and `run`
* binaries (no `.exe` — BSDs don't use it).
* - `GH_TOKEN` — token with `contents: write` on this repo.
*
* @param {Pick<import('@actions/github-script').AsyncFunctionArguments, 'core' | 'exec'>} args
*/
export default async function packageReleaseAsset({ core, exec }) {
const releaseTag = env.RELEASE_TAG;
const target = env.TARGET;
const binDir = env.BIN_DIR;
if (!releaseTag || !target || !binDir) {
fail(core, "RELEASE_TAG, TARGET, and BIN_DIR env vars are required");
return;
}

// Defensive: this script doesn't handle .exe binaries. The release.yml
// matrix only routes BSDs through cargo-build-std today, but a future
// config could route a Windows target here and silently produce a
// broken archive. Bail loudly instead.
if (target.includes("windows")) {
fail(core, "package-release-asset.mjs does not handle Windows targets (.exe naming)");
return;
}

const archiveBasename = `runner-${releaseTag}-${target}`;
const archive = `${archiveBasename}.tar.gz`;
// `<basename>.sha256`, NOT `<basename>.tar.gz.sha256`. Matches the
// convention `taiki-e/upload-rust-binary-action` uses, which is what
// verify-checksum.mjs enforces.
const checksum = `${archiveBasename}.sha256`;

const staging = await mkdtemp(join(tmpdir(), "package-release-asset-"));
try {
// Lay out the contents the way upload-rust-binary-action does with
// `leading_dir: false` and `include: README.md,LICENSE`: every file at
// the tarball root, no wrapper directory. build-packages.ts only
// matches by basename, but verify-checksum.mjs and any user inspecting
// the archive expect this exact layout.
for (const bin of ["runner", "run"]) {
const src = join(binDir, bin);
try {
await copyFile(src, join(staging, bin));
} catch {
fail(core, `${src} not found — build step did not produce ${bin}`);
return;
}
Comment on lines +68 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Nya~ The error handling here is too vague, onii-chan! 💢

The empty catch block swallows ALL errors from copyFile() — not just ENOENT (file not found), but also EACCES (permission denied), ENOSPC (disk full), and other I/O errors. The generic "not found" message will be confusing if the real problem is permissions or disk space.

🎀 More specific error handling
 		for (const bin of ["runner", "run"]) {
 			const src = join(binDir, bin);
 			try {
 				await copyFile(src, join(staging, bin));
-			} catch {
-				fail(core, `${src} not found — build step did not produce ${bin}`);
+			} catch (err) {
+				const reason = err.code === 'ENOENT' 
+					? 'not found — build step did not produce'
+					: `failed to copy: ${err.message}`;
+				fail(core, `${src} ${reason} ${bin}`);
 				return;
 			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
await copyFile(src, join(staging, bin));
} catch {
fail(core, `${src} not found — build step did not produce ${bin}`);
return;
}
try {
await copyFile(src, join(staging, bin));
} catch (err) {
const reason = err.code === 'ENOENT'
? 'not found — build step did not produce'
: `failed to copy: ${err.message}`;
fail(core, `${src} ${reason} ${bin}`);
return;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/build/package-release-asset.mjs around lines 68 - 73, The
catch is swallowing all copyFile errors and always reporting "not found"; update
the try/catch around the await copyFile(src, join(staging, bin)) to capture the
thrown error (e.g., `err`), check err.code and if err.code === 'ENOENT' call
fail(core, `${src} not found — build step did not produce ${bin}`), otherwise
call fail(core, `${src} copy failed: ${err.message}`) (or include err) so
permission, disk, and other I/O errors are reported accurately; keep the
existing return behavior after failing.

await chmod(join(staging, bin), 0o755);
}
await copyFile("README.md", join(staging, "README.md"));
await copyFile("LICENSE", join(staging, "LICENSE"));

await exec.exec("tar", ["-C", staging, "-czf", archive, "runner", "run", "README.md", "LICENSE"]);

// `<hex> <basename>` — the exact line `sha256sum` emits when invoked
// from the archive's directory, which verify-checksum.mjs requires.
const digest = createHash("sha256").update(await readFile(archive)).digest("hex");
await writeFile(checksum, `${digest} ${archive}\n`, "utf8");

await exec.exec("gh", ["release", "upload", releaseTag, archive, checksum, "--clobber"]);
} finally {
await rm(staging, { recursive: true, force: true });
}

await core.summary
.addHeading("Release asset packaged")
.addRaw(`Uploaded ${archive} and ${checksum} to ${releaseTag}.`)
.write();
}

/**
* @param {Core} core
* @param {string} message
*/
function fail(core, message) {
core.error(message, { file: ".github/workflows/release.yml", title: "Release asset packaging failed" });
core.setFailed(message);
}
Loading