-
Notifications
You must be signed in to change notification settings - Fork 0
ci: port release shell scripts to Node ESM #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
This file was deleted.
| 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); | ||
| } |
This file was deleted.
| 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; | ||
| } | ||
|
|
||
| const archive = `runner-${releaseTag}-man.tar.gz`; | ||
| const checksum = `runner-${releaseTag}-man.sha256`; | ||
|
|
||
| await exec.exec("tar", ["-C", "man", "-czf", archive, "."]); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Arrr, this tar command be lookin' mighty suspicious! 🏴☠️ Ye be usin' 🗺️ 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 |
||
|
|
||
| // `<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); | ||
| } | ||
| 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; | ||
| } | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🎀 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Arrr! Both scripts be vulnerable to the same path traversal scurvy! 🏴☠️
Both
man-archive.mjsandpackage-release-asset.mjsacceptRELEASE_TAG(andpackage-release-asset.mjsalso acceptsTARGET) from environment variables and immediately use them in filename construction without validation. A malicious tag like../../etc/passwdorv1.0/../evilcould write files outside the intended directory. Apply consistent semver validation toRELEASE_TAGin both scripts, and validate thatTARGETcontains no path separators.🤖 Prompt for AI Agents