diff --git a/.github/scripts/build/build-npm-packages.sh b/.github/scripts/build/build-npm-packages.sh deleted file mode 100644 index 7e206fc..0000000 --- a/.github/scripts/build/build-npm-packages.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -RELEASE_TAG="${RELEASE_TAG:?RELEASE_TAG required}" - -version="${RELEASE_TAG#v}" - -# Man pages come from the `man` job's artifact, downloaded to ./man. -man_arg=() -[[ -d man ]] && man_arg=(--man-dir man) - -# build-packages.ts is tier-aware: tier-3 (experimental) targets are -# silently skipped when missing; tier-1/2 missing fails the job. This -# script is only invoked from release.yml's build-npm-dist (tag-push -# context), so a missing tier-1/2 tarball is always a real failure — -# no --skip-missing relaxation. The flag still exists in the script -# for local dev partial builds. -node npm/scripts/build-packages.ts --version "${version}" "${man_arg[@]}" diff --git a/.github/scripts/build/derive-dist-dry.sh b/.github/scripts/build/derive-dist-dry.sh deleted file mode 100644 index dd2485e..0000000 --- a/.github/scripts/build/derive-dist-dry.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -RELEASE_TAG="${RELEASE_TAG:?RELEASE_TAG required}" -EVENT_NAME="${EVENT_NAME:?EVENT_NAME required}" -INPUT_DIST_TAG="${INPUT_DIST_TAG-}" -INPUT_DRY_RUN="${INPUT_DRY_RUN-false}" -GITHUB_OUTPUT="${GITHUB_OUTPUT:?GITHUB_OUTPUT required}" - -if [[ -n "${INPUT_DIST_TAG}" ]]; then - # Manual override always wins. Validate shape so a malformed input - # can't slip flag-like or whitespace values into `npm publish --tag`. - # npm dist-tags must start with a letter and use [A-Za-z0-9._-] only; - # they also must not parse as semver (npm enforces this server-side). - if [[ ! "${INPUT_DIST_TAG}" =~ ^[A-Za-z][A-Za-z0-9._-]*$ ]]; then - echo "error: INPUT_DIST_TAG '${INPUT_DIST_TAG}' is not a valid npm dist-tag (^[A-Za-z][A-Za-z0-9._-]*$)" >&2 - exit 1 - fi - dist_tag="${INPUT_DIST_TAG}" -else - # Infer from the tag: prerelease (e.g. v1.0.0-rc.1) → next, else latest. - case "${RELEASE_TAG}" in - *-*) dist_tag=next ;; - *) dist_tag=latest ;; - esac -fi - -if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then - # Normalize to strict true/false so downstream `[[ "${DRY_RUN}" == "true" ]]` - # checks aren't fooled by "True"/"1"/"yes" silently meaning dry_run=false. - case "${INPUT_DRY_RUN,,}" in - true) dry_run=true ;; - false | "") dry_run=false ;; - *) - echo "error: INPUT_DRY_RUN '${INPUT_DRY_RUN}' must be 'true' or 'false'" >&2 - exit 1 - ;; - esac -else - dry_run=false -fi - -{ - echo "dist-tag=${dist_tag}" - echo "dry-run=${dry_run}" -} | tee -a "${GITHUB_OUTPUT}" diff --git a/.github/scripts/build/download-release-archives.mjs b/.github/scripts/build/download-release-archives.mjs new file mode 100644 index 0000000..c8b8311 --- /dev/null +++ b/.github/scripts/build/download-release-archives.mjs @@ -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--.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} 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); +} diff --git a/.github/scripts/build/download-release-archives.sh b/.github/scripts/build/download-release-archives.sh deleted file mode 100644 index 9db1e13..0000000 --- a/.github/scripts/build/download-release-archives.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -RELEASE_TAG="${RELEASE_TAG:?RELEASE_TAG required}" -GITHUB_REPOSITORY="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY required}" - -# Scrub before fetch: stale .tar.gz/.sha256 from a previous tag would pass -# verify-checksum.sh (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. -rm -rf npm/downloads -mkdir -p npm/downloads -gh release download "${RELEASE_TAG}" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern 'runner-*-*.tar.gz' \ - --pattern 'runner-*-*.sha256' \ - --dir npm/downloads -ls -la npm/downloads diff --git a/.github/scripts/build/man-archive.mjs b/.github/scripts/build/man-archive.mjs new file mode 100644 index 0000000..803a731 --- /dev/null +++ b/.github/scripts/build/man-archive.mjs @@ -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} 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, "."]); + + // ` `, 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); +} diff --git a/.github/scripts/build/npm.mjs b/.github/scripts/build/npm.mjs new file mode 100644 index 0000000..5a34212 --- /dev/null +++ b/.github/scripts/build/npm.mjs @@ -0,0 +1,42 @@ +// @ts-check + +import { access } from "node:fs/promises"; + +const TAG_RE = /^v(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/; + +/** + * @param {Pick} 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} + */ +async function exists(path) { + try { + await access(path); + return true; + } catch { + return false; + } +} diff --git a/.github/scripts/build/package-release-asset.mjs b/.github/scripts/build/package-release-asset.mjs new file mode 100644 index 0000000..ce5652b --- /dev/null +++ b/.github/scripts/build/package-release-asset.mjs @@ -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} 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`; + // `.sha256`, NOT `.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; + } + 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"]); + + // ` ` — 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); +} diff --git a/.github/scripts/build/package-release-asset.sh b/.github/scripts/build/package-release-asset.sh deleted file mode 100755 index d67fca0..0000000 --- a/.github/scripts/build/package-release-asset.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -# -# 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. - -set -euo pipefail - -: "${RELEASE_TAG:?RELEASE_TAG required}" -: "${TARGET:?TARGET required}" -: "${BIN_DIR:?BIN_DIR required}" -: "${GH_TOKEN:?GH_TOKEN required}" - -# 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}" == *windows* ]]; then - echo "error: ${0##*/} does not handle Windows targets (.exe naming)" >&2 - exit 1 -fi - -archive_basename="runner-${RELEASE_TAG}-${TARGET}" -archive="${archive_basename}.tar.gz" -# `.sha256`, NOT `.tar.gz.sha256`. Matches the -# convention `taiki-e/upload-rust-binary-action` uses, which is what -# verify-checksum.sh enforces (`expected="${t%.tar.gz}.sha256"`). -checksum="${archive_basename}.sha256" - -staging=$(mktemp -d) -trap 'rm -rf "${staging}"' EXIT - -# 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.sh and any user inspecting the archive -# expect this exact layout. -for bin in runner run; do - src="${BIN_DIR}/${bin}" - if [[ ! -f "${src}" ]]; then - echo "error: ${src} not found — build step did not produce ${bin}" >&2 - exit 1 - fi - cp "${src}" "${staging}/${bin}" - chmod +x "${staging}/${bin}" -done -cp README.md LICENSE "${staging}/" - -tar -C "${staging}" -czf "${archive}" runner run README.md LICENSE - -# verify-checksum.sh requires the listed name in the .sha256 to match the -# archive's basename exactly (no path component). `sha256sum` writes the -# basename when invoked from the file's directory, so cd in. -sha256sum "${archive}" >"${checksum}" - -gh release upload "${RELEASE_TAG}" "${archive}" "${checksum}" --clobber diff --git a/.github/scripts/build/verify-checksum.mjs b/.github/scripts/build/verify-checksum.mjs new file mode 100644 index 0000000..563bf1f --- /dev/null +++ b/.github/scripts/build/verify-checksum.mjs @@ -0,0 +1,85 @@ +// @ts-check + +import { createHash } from "node:crypto"; +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { cwd, env } from "node:process"; + +/** @typedef {import('@actions/github-script').AsyncFunctionArguments['core']} Core */ + +// ` ` as written by `sha256sum`; the optional `*` marks +// binary mode and is not part of the filename. +const SUM_LINE_RE = /^(?[0-9a-f]{64})\s+\*?(?\S+)$/; + +/** + * Verify every downloaded release tarball against its `.sha256` companion. + * + * Required env: + * - `RELEASE_TAG` — git tag, used in error messages only. + * + * @param {Pick} args + */ +export default async function verifyChecksums({ core }) { + const releaseTag = env.RELEASE_TAG ?? "(unknown tag)"; + const dir = join(env.GITHUB_WORKSPACE ?? cwd(), "npm", "downloads"); + + const files = await readdir(dir); + const tarballs = files.filter((name) => name.endsWith(".tar.gz")); + const sums = files.filter((name) => name.endsWith(".sha256")); + + // Refuse to proceed unless every tarball has a matching .sha256 and + // every .sha256 has a matching tarball — otherwise an unchecksummed + // binary could slip through to publish. + if (tarballs.length === 0) { + fail(core, `no tarballs downloaded for ${releaseTag}`); + return; + } + for (const tarball of tarballs) { + const expected = tarball.replace(/\.tar\.gz$/, ".sha256"); + if (!sums.includes(expected)) { + fail(core, `tarball ${tarball} has no matching ${expected}`); + return; + } + } + + for (const sum of sums) { + const expected = sum.replace(/\.sha256$/, ".tar.gz"); + if (!tarballs.includes(expected)) { + fail(core, `checksum file ${sum} has no matching ${expected}`); + return; + } + + const line = (await readFile(join(dir, sum), "utf8")).trim(); + const parsed = SUM_LINE_RE.exec(line)?.groups; + if (parsed === undefined) { + fail(core, `malformed checksum file ${sum}: '${line}'`); + return; + } + + // Verify each .sha256 references a tarball whose name matches its + // own basename — defends against a release where foo.sha256 was + // swapped to reference bar.tar.gz, which would leave foo.tar.gz + // unchecked while a plain `sha256sum -c` silently re-verifies bar. + if (parsed.name !== expected) { + fail(core, `${sum} references '${parsed.name}', expected '${expected}'`); + return; + } + + const digest = createHash("sha256").update(await readFile(join(dir, expected))).digest("hex"); + if (digest !== parsed.hex) { + fail(core, `checksum mismatch for ${expected}: expected ${parsed.hex}, got ${digest}`); + return; + } + } + + core.info(`Verified ${tarballs.length} tarballs against their checksums.`); +} + +/** + * @param {Core} core + * @param {string} message + */ +function fail(core, message) { + core.error(message, { file: ".github/workflows/release.yml", title: "Checksum verification failed" }); + core.setFailed(message); +} diff --git a/.github/scripts/build/verify-checksum.sh b/.github/scripts/build/verify-checksum.sh deleted file mode 100644 index fb9af66..0000000 --- a/.github/scripts/build/verify-checksum.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail -shopt -s nullglob - -RELEASE_TAG="${RELEASE_TAG:?RELEASE_TAG required}" - -tarballs=(*.tar.gz) -sums=(*.sha256) - -# Refuse to proceed unless every tarball has a matching .sha256 and -# every .sha256 has a matching tarball — otherwise an unchecksummed -# binary could slip through to publish. -if [[ "${#tarballs[@]}" -eq 0 ]]; then - echo "error: no tarballs downloaded for ${RELEASE_TAG}" >&2 - exit 1 -fi -for t in "${tarballs[@]}"; do - expected="${t%.tar.gz}.sha256" - if [[ ! -f "${expected}" ]]; then - echo "error: tarball ${t} has no matching ${expected}" >&2 - exit 1 - fi -done - -# Verify each .sha256 references a tarball whose name matches its -# own basename — defends against a release where foo.sha256 was -# swapped to reference bar.tar.gz, which would leave foo.tar.gz -# unchecked while sha256sum -c silently re-verifies bar. -for s in "${sums[@]}"; do - inner=$(awk '{sub(/^\*/, "", $2); print $2}' "${s}") - expected="${s%.sha256}.tar.gz" - if [[ ! -f "${expected}" ]]; then - echo "error: checksum file ${s} has no matching ${expected}" >&2 - exit 1 - fi - if [[ "${inner}" != "${expected}" ]]; then - echo "error: ${s} references '${inner}', expected '${expected}'" >&2 - exit 1 - fi -done - -for sum in "${sums[@]}"; do - sha256sum -c --status "${sum}" -done diff --git a/.github/scripts/publish/aur-prepare.sh b/.github/scripts/publish/aur-prepare.sh deleted file mode 100755 index 8e72052..0000000 --- a/.github/scripts/publish/aur-prepare.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -# Substitute the release version into an AUR PKGBUILD and, for the prebuilt -# (-bin) package, inject per-arch sha256 sums read from the release's -# published `.sha256` companion assets. -# -# Why not `updpkgsums`? On an x86_64 runner it only fetches sources matching -# the host $CARCH, leaving sha256sums_aarch64 / _armv7h untouched. Reading the -# already-published checksum assets covers every arch without downloading the -# (multi-MB) tarballs. The source package has a single arch-independent source, -# so it lets the deploy action run `updpkgsums` instead (see the workflow). -# -# Usage: aur-prepare.sh -# Requires: GH_TOKEN, GITHUB_REPOSITORY (provided by Actions). -set -euo pipefail - -pkgname="${1:?usage: aur-prepare.sh }" -version="${2:?usage: aur-prepare.sh }" -pkgbuild="aur/${pkgname}/PKGBUILD" - -# Reject anything that isn't strict semver (with optional prerelease) before -# touching files. The downstream `sed -i ".../pkgver=${version}/"` would -# otherwise be at the mercy of `&` (sed backreference), `/` (delimiter), -# `\`, and newlines in whatever the workflow handed us. Keeping the alphabet -# to [0-9A-Za-z.-] guarantees the substitution is byte-for-byte literal. -if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "error: version '${version}' does not match semver (X.Y.Z or X.Y.Z-prerelease)" >&2 - exit 1 -fi - -if [[ ! -f "${pkgbuild}" ]]; then - echo "error: ${pkgbuild} not found" >&2 - exit 1 -fi - -# Fresh upstream version → pkgver bump, pkgrel back to 1. -sed -i -E "s/^pkgver=.*/pkgver=${version}/" "${pkgbuild}" -sed -i -E "s/^pkgrel=.*/pkgrel=1/" "${pkgbuild}" - -if [[ "${pkgname}" == 'runner-run-bin' ]]; then - # Arch CARCH -> Rust triple. Keep in lockstep with the source_ - # arrays in aur/runner-run-bin/PKGBUILD. - declare -A triples=( - [x86_64]='x86_64-unknown-linux-gnu' - [aarch64]='aarch64-unknown-linux-gnu' - [armv7h]='armv7-unknown-linux-gnueabihf' - ) - for carch in "${!triples[@]}"; do - asset="runner-v${version}-${triples[$carch]}.sha256" - # `.sha256` asset is " "; field 1 is the digest. - sum="$(gh release download "v${version}" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "${asset}" --output - | awk 'NR==1{print $1}')" - if [[ ! "${sum}" =~ ^[0-9a-f]{64}$ ]]; then - echo "error: bad sha256 for ${asset}: '${sum}'" >&2 - exit 1 - fi - sed -i -E "s/^sha256sums_${carch}=\(.*/sha256sums_${carch}=('${sum}')/" "${pkgbuild}" - done - - # Arch-independent man archive (generic sha256sums=()). - man_sum="$(gh release download "v${version}" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "runner-v${version}-man.sha256" --output - | awk 'NR==1{print $1}')" - if [[ ! "${man_sum}" =~ ^[0-9a-f]{64}$ ]]; then - echo "error: bad sha256 for man archive: '${man_sum}'" >&2 - exit 1 - fi - sed -i -E "s/^sha256sums=\(.*/sha256sums=('${man_sum}')/" "${pkgbuild}" -fi - -echo "--- prepared ${pkgbuild} ---" -cat "${pkgbuild}" diff --git a/.github/scripts/publish/aur.mjs b/.github/scripts/publish/aur.mjs new file mode 100644 index 0000000..de49fb9 --- /dev/null +++ b/.github/scripts/publish/aur.mjs @@ -0,0 +1,166 @@ +// @ts-check + +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { cwd, env } from "node:process"; + +const TAG_RE = /^v(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/; +const SHA256_RE = /^[0-9a-f]{64}$/; +const BIN_TRIPLES = { + x86_64: "x86_64-unknown-linux-gnu", + aarch64: "aarch64-unknown-linux-gnu", + armv7h: "armv7-unknown-linux-gnueabihf", +}; + +/** @typedef {import('@actions/github-script').AsyncFunctionArguments['core']} Core */ +/** @typedef {import('@actions/github-script').AsyncFunctionArguments['github']} GitHub */ +/** @typedef {import('@actions/github-script').AsyncFunctionArguments['context']} Context */ +/** @typedef {{ name: string, id: number }} ReleaseAsset */ + +/** + * @param {Pick} args + */ +export default async function prepareAurPkgbuild({ core, github, context }) { + const tag = context.payload.release?.tag_name ?? context.payload.inputs?.tag ?? ""; + const version = TAG_RE.exec(tag)?.groups?.version; + if (version === undefined) { + core.error(`invalid tag: ${tag}`, { + file: ".github/workflows/aur-release.yml", + title: "Invalid release tag", + }); + core.setFailed("invalid release tag"); + return; + } + + const pkgname = env.PKGNAME; + if (pkgname !== "runner-run" && pkgname !== "runner-run-bin") { + core.error(`invalid AUR package: ${pkgname}`, { + file: ".github/workflows/aur-release.yml", + title: "Invalid AUR package", + }); + core.setFailed("invalid AUR package"); + return; + } + + const pkgbuild = join(env.GITHUB_WORKSPACE ?? cwd(), "aur", pkgname, "PKGBUILD"); + let content = await readFile(pkgbuild, "utf8"); + const versioned = replaceLine(core, content, /^pkgver=.*/m, `pkgver=${version}`, "pkgver"); + if (versioned === undefined) return; + const resetPkgrel = replaceLine(core, versioned, /^pkgrel=.*/m, "pkgrel=1", "pkgrel"); + if (resetPkgrel === undefined) return; + content = resetPkgrel; + + if (pkgname === "runner-run-bin") { + const release = await github.rest.repos.getReleaseByTag({ + ...context.repo, + tag, + }); + /** @type {ReleaseAsset[]} */ + const assets = await github.paginate(github.rest.repos.listReleaseAssets, { + ...context.repo, + release_id: release.data.id, + per_page: 100, + }); + + for (const [carch, triple] of Object.entries(BIN_TRIPLES)) { + const sum = await checksumForAsset({ + assets, + context, + core, + github, + name: `runner-v${version}-${triple}.sha256`, + }); + if (sum === undefined) return; + const withArchSum = replaceLine( + core, + content, + new RegExp(`^sha256sums_${carch}=\\(.*`, "m"), + `sha256sums_${carch}=('${sum}')`, + `sha256sums_${carch}`, + ); + if (withArchSum === undefined) return; + content = withArchSum; + } + + const manSum = await checksumForAsset({ + assets, + context, + core, + github, + name: `runner-v${version}-man.sha256`, + }); + if (manSum === undefined) return; + const withManSum = replaceLine(core, content, /^sha256sums=\(.*/m, `sha256sums=('${manSum}')`, "sha256sums"); + if (withManSum === undefined) return; + content = withManSum; + } + + await writeFile(pkgbuild, content, "utf8"); + await core.summary + .addHeading("AUR PKGBUILD") + .addRaw(`Prepared ${pkgname} ${version}.`) + .write(); +} + +/** + * @param {{ assets: ReleaseAsset[], context: Context, core: Core, github: GitHub, name: string }} input + * @returns {Promise} + */ +async function checksumForAsset({ assets, context, core, github, name }) { + const asset = assets.find((candidate) => candidate.name === name); + if (asset === undefined) { + fail(core, `release asset not found: ${name}`); + return undefined; + } + + const response = await github.request("GET /repos/{owner}/{repo}/releases/assets/{asset_id}", { + ...context.repo, + asset_id: asset.id, + headers: { accept: "application/octet-stream" }, + }); + const text = toText(response.data); + const sum = text.trim().split(/\s+/)[0] ?? ""; + if (!SHA256_RE.test(sum)) { + fail(core, `bad sha256 for ${name}: '${sum}'`); + return undefined; + } + return sum; +} + +/** + * @param {unknown} data + * @returns {string} + */ +function toText(data) { + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) return new TextDecoder().decode(data); + if (ArrayBuffer.isView(data)) { + return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)); + } + return ""; +} + +/** + * @param {Core} core + * @param {string} content + * @param {RegExp} pattern + * @param {string} replacement + * @param {string} label + * @returns {string | undefined} + */ +function replaceLine(core, content, pattern, replacement, label) { + if (!pattern.test(content)) { + fail(core, `PKGBUILD line not found: ${label}`); + return undefined; + } + return content.replace(pattern, replacement); +} + +/** + * @param {Core} core + * @param {string} message + */ +function fail(core, message) { + core.error(message, { file: ".github/workflows/aur-release.yml", title: "AUR prepare failed" }); + core.setFailed(message); +} diff --git a/.github/scripts/publish/npm.mjs b/.github/scripts/publish/npm.mjs new file mode 100644 index 0000000..af0ff63 --- /dev/null +++ b/.github/scripts/publish/npm.mjs @@ -0,0 +1,443 @@ +// @ts-check + +import { access, readdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +const TIMEOUT = "120s"; +const CONFLICT_RE = /EPUBLISHCONFLICT|cannot publish over the previously published versions/i; +const TAG_RE = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const DIST_TAG_RE = /^[A-Za-z][A-Za-z0-9._-]*$/; + +/** @typedef {import('@actions/github-script').AsyncFunctionArguments['core']} Core */ +/** @typedef {import('@actions/github-script').AsyncFunctionArguments['context']} Context */ +/** @typedef {import('@actions/github-script').AsyncFunctionArguments['github']} GitHub */ +/** @typedef {import('@actions/github-script').AsyncFunctionArguments['exec']} Exec */ +/** @typedef {{ pkg: string, experimental?: boolean }} Target */ +/** @typedef {{ facade: string, scope: string, targets: Target[] }} TargetsConfig */ +/** @typedef {{ name: string, status: string }} PublishSummary */ +/** @typedef {{ ok: true, summary: PublishSummary } | { ok: false }} PublishResult */ +/** @typedef {{ name?: string, version?: string, publishConfig?: unknown, optionalDependencies?: Record }} PackageJson */ +/** @typedef {{ core: Core, exec: Exec, dir: string, distTag: string, dryRun: boolean, expectedName: string, expectedVersion: string, facade: string, registry: string, required: boolean, requiredPlatforms: string[], optionalPlatforms: string[], scope: string }} PublishOptions */ + +/** + * @param {Pick} args + */ +export async function resolveReleaseRun({ core, github, context }) { + const tag = context.payload.release?.tag_name ?? context.payload.inputs?.tag ?? ""; + if (!TAG_RE.test(tag)) { + core.error(`invalid tag: ${tag}`, { + file: ".github/workflows/npm-release.yml", + title: "Invalid release tag", + }); + core.setFailed("invalid release tag"); + return; + } + + const runId = await resolveRunId({ core, context, github, tag }); + if (runId === undefined) return; + const ok = await waitForReleaseRun({ core, context, github, runId }); + if (!ok) return; + + const value = String(runId); + core.setOutput("run-id", value); + await core.summary + .addHeading("Release artifact ready") + .addRaw(`Using release workflow run ${runId} for ${tag}.`) + .write(); +} + +/** + * @param {Pick} args + */ +export async function deriveNpmPublishSettings({ core, context }) { + const tag = context.payload.release?.tag_name ?? context.payload.inputs?.tag ?? ""; + if (!TAG_RE.test(tag)) { + core.error(`invalid tag: ${tag}`, { + file: ".github/workflows/npm-release.yml", + title: "Invalid release tag", + }); + core.setFailed("invalid release tag"); + return; + } + + const distTag = context.payload.inputs?.["dist-tag"] ?? (tag.includes("-") ? "next" : "latest"); + const dryRun = String(context.payload.inputs?.["dry-run"] ?? "false").toLowerCase(); + if (!DIST_TAG_RE.test(distTag)) { + core.error(`invalid npm dist-tag: ${distTag}`, { + file: ".github/workflows/npm-release.yml", + title: "Invalid npm dist-tag", + }); + core.setFailed("invalid npm dist-tag"); + return; + } + if (dryRun !== "true" && dryRun !== "false") { + core.error(`dry-run must be true or false: ${dryRun}`, { + file: ".github/workflows/npm-release.yml", + title: "Invalid dry-run input", + }); + core.setFailed("invalid dry-run input"); + return; + } + + core.setOutput("dist-tag", distTag); + core.setOutput("dry-run", dryRun); + await core.summary + .addHeading("npm publish settings") + .addList([`dist-tag: ${distTag}`, `dry-run: ${dryRun}`]) + .write(); +} + +/** + * @param {Pick} args + */ +export default async function publishNpmPackages({ core, exec }) { + const derive = parseJsonInput(core, "derive_outputs"); + const workflowEnv = parseJsonInput(core, "workflow_env"); + if (derive === undefined || workflowEnv === undefined) return; + + const releaseTag = stringField(core, workflowEnv, "RELEASE_TAG"); + const registry = stringField(core, workflowEnv, "REGISTRY"); + const distTag = stringField(core, derive, "dist-tag"); + const dryRun = booleanStringField(core, derive, "dry-run"); + if (releaseTag === undefined || registry === undefined || distTag === undefined || dryRun === undefined) return; + const expectedVersion = releaseTag.replace(/^v/, ""); + const workspace = process.env.GITHUB_WORKSPACE ?? process.cwd(); + const distRoot = path.join(workspace, "npm", "dist"); + /** @type {TargetsConfig} */ + const targetsConfig = JSON.parse( + await readFile(path.join(workspace, "npm", "targets.json"), "utf8"), + ); + const requiredPlatforms = targetsConfig.targets + .filter((target) => !(target.experimental ?? false)) + .map((target) => target.pkg); + const optionalPlatforms = targetsConfig.targets + .filter((target) => target.experimental ?? false) + .map((target) => target.pkg); + const allowedPackages = new Set([ + targetsConfig.facade, + ...requiredPlatforms, + ...optionalPlatforms, + ]); + /** @type {PublishSummary[]} */ + const results = []; + + for (const entry of await readdir(distRoot, { withFileTypes: true })) { + if (entry.isDirectory() && !allowedPackages.has(entry.name)) { + return fail(core, `artifact contains unexpected directory '${entry.name}'`); + } + } + + for (const platform of requiredPlatforms) { + const result = await publishAllowed({ + core, + exec, + dir: path.join(distRoot, platform), + distTag, + dryRun, + expectedName: `${targetsConfig.scope}/${platform}`, + expectedVersion, + facade: targetsConfig.facade, + registry, + required: true, + requiredPlatforms, + optionalPlatforms, + scope: targetsConfig.scope, + }); + if (!result.ok) return; + results.push(result.summary); + } + for (const platform of optionalPlatforms) { + const result = await publishAllowed({ + core, + exec, + dir: path.join(distRoot, platform), + distTag, + dryRun, + expectedName: `${targetsConfig.scope}/${platform}`, + expectedVersion, + facade: targetsConfig.facade, + registry, + required: false, + requiredPlatforms, + optionalPlatforms, + scope: targetsConfig.scope, + }); + if (!result.ok) return; + results.push(result.summary); + } + const facadeResult = await publishAllowed({ + core, + exec, + dir: path.join(distRoot, targetsConfig.facade), + distTag, + dryRun, + expectedName: targetsConfig.facade, + expectedVersion, + facade: targetsConfig.facade, + registry, + required: true, + requiredPlatforms, + optionalPlatforms, + scope: targetsConfig.scope, + }); + if (!facadeResult.ok) return; + results.push(facadeResult.summary); + + await core.summary + .addHeading("npm publish") + .addList(results.map(({ name, status }) => `${name}: ${status}`)) + .write(); +} + +/** + * @param {PublishOptions} options + * @returns {Promise} + */ +async function publishAllowed(options) { + const { + core, + exec, + dir, + distTag, + dryRun, + expectedName, + expectedVersion, + facade, + registry, + required, + requiredPlatforms, + optionalPlatforms, + scope, + } = options; + if (!(await exists(dir))) { + if (required) return fail(core, `required package ${expectedName} missing from artifact`); + return { ok: true, summary: { name: expectedName, status: "skipped missing optional artifact" } }; + } + + const packagePath = path.join(dir, "package.json"); + if (!(await exists(packagePath))) return fail(core, `${packagePath} missing`); + if (await exists(path.join(dir, ".npmrc"))) { + return fail(core, `${dir}/.npmrc is forbidden because it could redirect publish`); + } + + /** @type {PackageJson} */ + const pkg = JSON.parse(await readFile(packagePath, "utf8")); + if (Object.prototype.hasOwnProperty.call(pkg, "publishConfig")) { + return fail(core, `${packagePath} has publishConfig, which could redirect publish`); + } + if (pkg.name !== expectedName) { + return fail(core, `${packagePath} declares name '${pkg.name}', expected '${expectedName}'`); + } + if (pkg.version !== expectedVersion) { + return fail( + core, + `${packagePath} declares version '${pkg.version}', expected '${expectedVersion}'`, + ); + } + + const optionalDependencies = pkg.optionalDependencies ?? {}; + if (expectedName === facade) { + const expectedDeps = new Set([...requiredPlatforms, ...optionalPlatforms]); + for (const [depName, depVersion] of Object.entries(optionalDependencies)) { + if (!depName.startsWith(`${scope}/`)) { + return fail(core, `facade optionalDependencies entry '${depName}' not under scope '${scope}'`); + } + const platform = depName.slice(scope.length + 1); + if (!expectedDeps.has(platform)) { + return fail(core, `facade optionalDependencies references unexpected package '${depName}'`); + } + if (depVersion !== expectedVersion) { + return fail(core, `facade optionalDependencies['${depName}'] = '${depVersion}', expected '${expectedVersion}'`); + } + } + for (const platform of requiredPlatforms) { + if (!Object.prototype.hasOwnProperty.call(optionalDependencies, `${scope}/${platform}`)) { + return fail(core, `facade optionalDependencies missing required package '${scope}/${platform}'`); + } + } + } else if (Object.keys(optionalDependencies).length > 0) { + return fail(core, `${packagePath} has optionalDependencies; only ${facade} may declare any`); + } + + core.setOutput("package-url", `https://npm.im/${expectedName}`); + const published = await exec.getExecOutput( + "timeout", + [TIMEOUT, "npm", "view", `${expectedName}@${expectedVersion}`, "--registry", registry, "version"], + { ignoreReturnCode: true, silent: true }, + ); + if (published.exitCode === 124) { + return fail(core, `npm view ${expectedName}@${expectedVersion} timed out after ${TIMEOUT}`); + } + if (published.stdout.trim() === expectedVersion) { + return { ok: true, summary: { name: expectedName, status: "already published" } }; + } + + const args = /* dprint-ignore */ [ + TIMEOUT, + "npm", "publish", + "--registry", registry, + "--access", "public", + "--tag", distTag, + "--ignore-scripts", + "--provenance", + ]; + if (dryRun) args.push("--dry-run"); + const publishedPackage = await exec.getExecOutput("timeout", args, { + cwd: dir, + ignoreReturnCode: true, + }); + if (publishedPackage.exitCode === 124) { + return fail(core, `npm publish for ${expectedName}@${expectedVersion} timed out after ${TIMEOUT}`); + } + if (publishedPackage.exitCode !== 0) { + const output = `${publishedPackage.stdout}\n${publishedPackage.stderr}`; + if (CONFLICT_RE.test(output)) { + return { ok: true, summary: { name: expectedName, status: "already published" } }; + } + return fail(core, `npm publish failed for ${expectedName}@${expectedVersion}`); + } + + return { ok: true, summary: { name: expectedName, status: dryRun ? "dry-run ok" : "published" } }; +} + +/** + * @param {string} filePath + * @returns {Promise} + */ +async function exists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +/** + * @param {{ core: Core, context: Context, github: GitHub, tag: string }} input + * @returns {Promise} + */ +async function resolveRunId({ core, context, github, tag }) { + if (context.eventName === "workflow_dispatch") { + const inputRunId = context.payload.inputs?.["run-id"] ?? ""; + if (!/^\d+$/.test(inputRunId)) { + core.error(`run-id is not a positive integer: ${inputRunId}`, { + file: ".github/workflows/npm-release.yml", + title: "Invalid release run id", + }); + core.setFailed("invalid release run id"); + return undefined; + } + return Number(inputRunId); + } + + core.warning("release event did not provide run-id; resolving latest successful release run", { + title: "Fallback run-id resolution", + }); + const runs = await github.rest.actions.listWorkflowRuns({ + ...context.repo, + workflow_id: "release.yml", + branch: tag, + status: "success", + per_page: 1, + }); + const runId = runs.data.workflow_runs[0]?.id; + if (runId === undefined) { + core.error(`no run-id resolvable for ${tag}`, { + file: ".github/workflows/npm-release.yml", + title: "Missing release run id", + }); + core.setFailed("missing release run id"); + } + return runId; +} + +/** + * @param {{ core: Core, context: Context, github: GitHub, runId: number }} input + * @returns {Promise} + */ +async function waitForReleaseRun({ core, context, github, runId }) { + core.startGroup(`Wait for release run ${runId}`); + try { + for (let attempt = 0; attempt < 120; attempt += 1) { + const run = await github.rest.actions.getWorkflowRun({ + ...context.repo, + run_id: runId, + }); + if (run.data.status === "completed") { + if (run.data.conclusion === "success") return true; + core.error(`release run ${runId} completed with ${run.data.conclusion}`, { + file: ".github/workflows/npm-release.yml", + title: "Release run failed", + }); + core.setFailed("release run failed"); + return false; + } + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + } finally { + core.endGroup(); + } + core.error(`timed out waiting for release run ${runId}`, { + file: ".github/workflows/npm-release.yml", + title: "Release run timeout", + }); + core.setFailed("release run timeout"); + return false; +} + +/** + * @param {Core} core + * @param {string} name + * @returns {Record | undefined} + */ +function parseJsonInput(core, name) { + const input = core.getInput(name, { required: true }); + try { + const value = JSON.parse(input); + if (value !== null && typeof value === "object" && !Array.isArray(value)) return value; + } catch { + // Fall through to one annotated failure path below. + } + fail(core, `${name} must be a JSON object`); + return undefined; +} + +/** + * @param {Core} core + * @param {Record} record + * @param {string} field + * @returns {string | undefined} + */ +function stringField(core, record, field) { + const value = record[field]; + if (typeof value === "string" && value !== "") return value; + fail(core, `${field} must be a non-empty string`); + return undefined; +} + +/** + * @param {Core} core + * @param {Record} record + * @param {string} field + * @returns {boolean | undefined} + */ +function booleanStringField(core, record, field) { + const value = stringField(core, record, field); + if (value === undefined) return undefined; + if (value === "true") return true; + if (value === "false") return false; + fail(core, `${field} must be true or false`); + return undefined; +} + +/** + * @param {Core} core + * @param {string} message + * @returns {{ ok: false }} + */ +function fail(core, message) { + core.error(message, { file: ".github/workflows/npm-release.yml", title: "npm publish failed" }); + core.setFailed(message); + return { ok: false }; +} diff --git a/.github/scripts/publish/npm.sh b/.github/scripts/publish/npm.sh deleted file mode 100644 index 4f419d1..0000000 --- a/.github/scripts/publish/npm.sh +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail -shopt -s nullglob - -# Required env vars supplied by the workflow. Declaring them as -# self-assignments with ${VAR:?} both fails fast on missing values -# and resolves shellcheck's "referenced but not assigned" warnings. -RELEASE_TAG="${RELEASE_TAG:?RELEASE_TAG required}" -DIST_TAG="${DIST_TAG:?DIST_TAG required}" -DRY_RUN="${DRY_RUN:?DRY_RUN required}" -REGISTRY="${REGISTRY:?REGISTRY required}" -# Optional: set by GHA, absent for local runs. -GITHUB_OUTPUT="${GITHUB_OUTPUT-}" - -# The artifact is built by release.yml's `build-npm-dist` job (tag-push -# context) and downloaded here via cross-workflow `download-artifact`. -# We still treat it as untrusted: defense-in-depth against a tampered -# artifact at the cross-workflow handoff or a malicious tag committer. -# Three defenses run before npm is invoked: -# 1. Hardcoded allowlist of expected directory names — a tampered -# artifact cannot smuggle extra package directories. -# 2. Each package.json's `name` field must equal the expected -# scope/key — prevents republishing as an unexpected package. -# 3. Each package.json's `version` field must equal the version -# derived from the trigger tag (trusted metadata) — prevents -# stamping arbitrary versions onto allowed packages. -# Single source of truth: npm/targets.json. `experimental: true` -# packages may legitimately be missing because their build matrix uses -# `continue-on-error`. Everything else is mandatory for release-triggered -# runs; for manual workflow_dispatch backfills we relax this so missing -# required packages are skipped instead of aborting. The façade itself -# remains mandatory either way. -TARGETS_JSON="${GITHUB_WORKSPACE:-.}/npm/targets.json" -FACADE=$(jq -r '.facade' "${TARGETS_JSON}") -SCOPE=$(jq -r '.scope' "${TARGETS_JSON}") -mapfile -t REQUIRED_PLATFORMS < <(jq -r '.targets[] | select((.experimental // false) | not) | .pkg' "${TARGETS_JSON}") -mapfile -t OPTIONAL_PLATFORMS < <(jq -r '.targets[] | select(.experimental // false) | .pkg' "${TARGETS_JSON}") -EXPECTED_VERSION="${RELEASE_TAG#v}" - -# Refuse to proceed if the artifact contains anything outside the -# allowlist — that's either a misconfiguration or an attack. -allowed_set=" ${FACADE} ${REQUIRED_PLATFORMS[*]} ${OPTIONAL_PLATFORMS[*]} " -for dir in npm/dist/*/; do - base=$(basename "${dir%/}") - if [[ "${allowed_set}" != *" ${base} "* ]]; then - echo "error: artifact contains unexpected directory '${base}' (not in allowlist)" >&2 - exit 1 - fi -done - -# publish_allowed publishes a single package from a built artifact directory -# when it exists and its package.json matches the expected name and version, -# skips optional or already-published packages, and exits on integrity or policy -# failures. -publish_allowed() { - local dir="$1" expected_name="$2" required="$3" - local actual_name version published - - if [[ ! -d "${dir}" ]]; then - if [[ "${required}" == "true" ]]; then - echo "error: required package ${expected_name} missing from artifact" >&2 - exit 1 - fi - echo "skip ${expected_name}: not in artifact (optional / experimental platform)" - return 0 - fi - if [[ ! -f "${dir}/package.json" ]]; then - echo "error: ${dir}/package.json missing" >&2 - exit 1 - fi - - # Reject per-package registry overrides. A malicious build could - # drop a .npmrc or set publishConfig in package.json to redirect - # the publish to an attacker-controlled registry. - # CLI --registry does NOT override scoped publishConfig.registry, - # so the rejection here is the primary defense; the explicit - # --registry flag below is belt-and-suspenders for non-scoped - # overrides. - if [[ -e "${dir}/.npmrc" ]]; then - echo "error: ${dir}/.npmrc is forbidden (could redirect publish)" >&2 - exit 1 - fi - if jq -e 'has("publishConfig")' "${dir}/package.json" >/dev/null; then - echo "error: ${dir}/package.json has publishConfig (could redirect publish)" >&2 - exit 1 - fi - - actual_name=$(jq -r .name "${dir}/package.json") - if [[ "${actual_name}" != "${expected_name}" ]]; then - echo "error: ${dir}/package.json declares name '${actual_name}', expected '${expected_name}'" >&2 - exit 1 - fi - version=$(jq -r .version "${dir}/package.json") - if [[ "${version}" != "${EXPECTED_VERSION}" ]]; then - echo "error: ${dir}/package.json declares version '${version}', expected '${EXPECTED_VERSION}' (from tag ${RELEASE_TAG})" >&2 - exit 1 - fi - - # optionalDependencies validation. The facade is the only package that - # legitimately ships optionalDependencies (one entry per built platform - # package, all pinned to EXPECTED_VERSION). Platform packages must have - # none — a tampered platform package could otherwise smuggle attacker- - # controlled deps that npm would happily install transitively. - if [[ "${expected_name}" == "${FACADE}" ]]; then - local dep_name dep_version platform expected_dep_set=" ${REQUIRED_PLATFORMS[*]} ${OPTIONAL_PLATFORMS[*]} " - while IFS=$'\t' read -r dep_name dep_version; do - [[ -z "${dep_name}" ]] && continue - if [[ "${dep_name}" != "${SCOPE}/"* ]]; then - echo "error: facade optionalDependencies entry '${dep_name}' not under scope '${SCOPE}'" >&2 - exit 1 - fi - platform="${dep_name#"${SCOPE}/"}" - if [[ "${expected_dep_set}" != *" ${platform} "* ]]; then - echo "error: facade optionalDependencies references unexpected package '${dep_name}'" >&2 - exit 1 - fi - if [[ "${dep_version}" != "${EXPECTED_VERSION}" ]]; then - echo "error: facade optionalDependencies['${dep_name}'] = '${dep_version}', expected '${EXPECTED_VERSION}'" >&2 - exit 1 - fi - done < <(jq -r '(.optionalDependencies // {}) | to_entries[] | "\(.key)\t\(.value)"' "${dir}/package.json") - - # Required platforms must all be referenced. - for platform in "${REQUIRED_PLATFORMS[@]}"; do - if ! jq -e --arg dep "${SCOPE}/${platform}" '(.optionalDependencies // {}) | has($dep)' "${dir}/package.json" >/dev/null; then - echo "error: facade optionalDependencies missing required package '${SCOPE}/${platform}'" >&2 - exit 1 - fi - done - else - if jq -e '(.optionalDependencies // {}) | length > 0' "${dir}/package.json" >/dev/null; then - echo "error: ${dir}/package.json has optionalDependencies; only ${FACADE} may declare any" >&2 - exit 1 - fi - fi - - # Surface the package URL to the workflow. Repeated writes to the - # same key resolve last-wins in GITHUB_OUTPUT, so the façade (which - # publishes last) ends up as the canonical value. - if [[ -n "${GITHUB_OUTPUT}" ]]; then - echo "package-url=https://npm.im/${actual_name}" >>"${GITHUB_OUTPUT}" - fi - - # Skip if already published — npm versions are immutable, so reruns - # after a partial publish would otherwise fail on the first - # sub-package that already published. Bound the probe at 120s so a - # hung registry can't stall the whole publish job. Non-timeout - # failures (e.g. E404 when the version isn't published yet) drop - # through to the publish step, which surfaces real errors. - local view_status=0 - published=$(timeout 120s npm view "${actual_name}@${version}" --registry "${REGISTRY}" version 2>/dev/null) || view_status=$? - if [[ ${view_status} -eq 124 ]]; then - echo "error: 'npm view ${actual_name}@${version}' timed out after 120s" >&2 - return 1 - fi - if [[ "${published}" == "${version}" ]]; then - echo "skip ${actual_name}@${version}: already published" - return 0 - fi - - local args=(publish --registry "${REGISTRY}" --access public --tag "${DIST_TAG}" --ignore-scripts --provenance) - if [[ "${DRY_RUN}" == "true" ]]; then args+=(--dry-run); fi - echo "+ npm ${args[*]} (cwd: ${dir})" - # Tolerate the TOCTOU race between the npm view check above and - # this publish: if another actor publishes the same version in - # the gap, npm exits with EPUBLISHCONFLICT and we treat that as a - # no-op (mirrors npm/scripts/publish.ts). - # - # The `|| status=$?` form is required: under `set -e`, - # `output=$(cmd); status=$?` would exit on a failing cmd before - # status was captured, and `if ! output=$(cmd); then status=$?` - # captures the negation status (always 0), not npm's real exit - # code — silently masking real publish failures. - local output status=0 - output=$(cd "${dir}" && timeout 120s npm "${args[@]}" 2>&1) || status=$? - if [[ "${status}" -eq 124 ]]; then - printf '%s\n' "${output}" >&2 - echo "error: 'npm publish' for ${actual_name}@${version} timed out after 120s" >&2 - return 1 - fi - if [[ "${status}" -ne 0 ]]; then - printf '%s\n' "${output}" >&2 - if grep -Eiq 'EPUBLISHCONFLICT|cannot publish over the previously published versions' <<<"${output}"; then - echo "skip ${actual_name}@${version}: already published (race with concurrent publisher)" - return 0 - fi - return "${status}" - fi - printf '%s\n' "${output}" -} - -# Tier-1/2 are always required: the artifact is built by release.yml's -# build-npm-dist (where missing tier-1/2 tarballs already fail loud), -# so a missing dir here means the artifact was tampered with or the -# build silently dropped a target — either case warrants a hard fail. -# Sub-packages first so the façade's optionalDependencies resolve on install. -for platform in "${REQUIRED_PLATFORMS[@]}"; do - publish_allowed "npm/dist/${platform}" "${SCOPE}/${platform}" true -done -for platform in "${OPTIONAL_PLATFORMS[@]}"; do - publish_allowed "npm/dist/${platform}" "${SCOPE}/${platform}" false -done - -# Façade is mandatory either way — no point publishing a half-empty -# set of platform packages with no entry point. -publish_allowed "npm/dist/${FACADE}" "${FACADE}" true diff --git a/.github/scripts/release/notes.mjs b/.github/scripts/release/notes.mjs new file mode 100644 index 0000000..115ebb1 --- /dev/null +++ b/.github/scripts/release/notes.mjs @@ -0,0 +1,43 @@ +// @ts-check + +const TAG_RE = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +/** + * @param {Pick} args + */ +export default async function appendGeneratedReleaseNotes({ core, github, context }) { + const tag = context.ref.replace(/^refs\/tags\//, ""); + if (!TAG_RE.test(tag)) { + core.error(`invalid tag: ${tag}`, { + file: ".github/workflows/release.yml", + title: "Invalid release tag", + }); + core.setFailed("invalid release tag"); + return; + } + + const { owner, repo } = context.repo; + const release = await github.rest.repos.getReleaseByTag({ owner, repo, tag }); + const generated = await github.rest.repos.generateReleaseNotes({ + owner, + repo, + tag_name: tag, + }); + const body = `${ + [release.data.body, generated.data.body] + .map((part) => (part ?? "").trim()) + .filter(Boolean) + .join("\n\n") + }\n`; + + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: release.data.id, + body, + }); + await core.summary + .addHeading("Release notes") + .addRaw(`Updated notes for ${tag}.`) + .write(); +} diff --git a/.github/scripts/release/publish.mjs b/.github/scripts/release/publish.mjs new file mode 100644 index 0000000..871560b --- /dev/null +++ b/.github/scripts/release/publish.mjs @@ -0,0 +1,77 @@ +// @ts-check + +const TAG_RE = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +/** @typedef {import('@actions/github-script').AsyncFunctionArguments['core']} Core */ +/** @typedef {import('@actions/github-script').AsyncFunctionArguments['github']} GitHub */ +/** @typedef {import('@actions/github-script').AsyncFunctionArguments['context']} Context */ + +/** + * @param {Pick} args + */ +export async function publishGithubRelease({ core, github, context }) { + const tag = tagFromRef(core, context); + if (tag === undefined) return; + + const release = await github.rest.repos.getReleaseByTag({ + ...context.repo, + tag, + }); + await github.rest.repos.updateRelease({ + ...context.repo, + release_id: release.data.id, + draft: false, + }); + core.notice(`Published GitHub release ${tag}`, { title: "Release published" }); +} + +/** + * @param {Pick} args + */ +export async function dispatchPackagePublishes({ core, github, context }) { + const tag = tagFromRef(core, context); + if (tag === undefined) return; + + await github.rest.actions.createWorkflowDispatch({ + ...context.repo, + workflow_id: "npm-release.yml", + ref: "master", + inputs: { + tag, + "run-id": String(context.runId), + "dry-run": "false", + }, + }); + await github.rest.actions.createWorkflowDispatch({ + ...context.repo, + workflow_id: "aur-release.yml", + ref: "master", + inputs: { + tag, + "dry-run": "false", + }, + }); + core.notice(`Dispatched npm and AUR publish workflows for ${tag}`, { + title: "Package publishes dispatched", + }); + await core.summary + .addHeading("Package publishes dispatched") + .addList([`npm-release.yml for ${tag}`, `aur-release.yml for ${tag}`]) + .write(); +} + +/** + * @param {Core} core + * @param {Context} context + * @returns {string | undefined} + */ +function tagFromRef(core, context) { + const tag = context.ref.replace(/^refs\/tags\//, ""); + if (TAG_RE.test(tag)) return tag; + core.error(`invalid tag: ${tag}`, { + file: ".github/workflows/release.yml", + title: "Invalid release tag", + }); + core.setFailed("invalid release tag"); + return undefined; +} diff --git a/.github/workflows/aur-release.yml b/.github/workflows/aur-release.yml index a2ed8f4..b52c751 100644 --- a/.github/workflows/aur-release.yml +++ b/.github/workflows/aur-release.yml @@ -1,22 +1,15 @@ name: aur-release -run-name: >- - ${{ format('aur publish {0}', case(github.event_name == 'release', - github.event.release.tag_name, - inputs.tag)) }} +run-name: ${{ format('aur publish {0}', case(github.event_name == 'release', github.event.release.tag_name, inputs.tag)) }} on: release: { types: [published] } workflow_dispatch: inputs: - tag: - description: Release tag to publish (e.g. v0.12.1) - required: true - type: string - dry-run: - description: Prepare + validate PKGBUILDs but do not push to AUR - required: true - default: false - type: boolean + tag: { description: Release tag to publish (e.g. v0.12.1), required: true, type: string } + dry-run: { description: Prepare + validate PKGBUILDs but do not push to AUR, required: true, default: false, type: boolean } + +permissions: { contents: read } +env: { RELEASE_TAG: "${{ case(github.event_name == 'release', github.event.release.tag_name, inputs.tag) }}" } jobs: publish: @@ -25,25 +18,23 @@ jobs: # Serialize per-pkg so dispatch + release-published can't race the AUR push. concurrency: { group: "aur-publish-${{ matrix.pkgname }}", cancel-in-progress: false } strategy: { fail-fast: false, matrix: { pkgname: [runner-run, runner-run-bin] } } - permissions: { contents: read } environment: { name: aur, url: "https://aur.archlinux.org/packages/${{ matrix.pkgname }}" } - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ case(github.event_name == 'release', github.event.release.tag_name, inputs.tag) }} - DRY_RUN: ${{ inputs.dry-run || 'false' }} steps: + # Checkout from default branch — no tag/branch-supplied code on disk, + # matching npm-release.yml. aur.mjs and PKGBUILDs come from master. - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: { persist-credentials: false } + with: { ref: "${{ github.event.repository.default_branch }}", persist-credentials: false } - name: prepare PKGBUILD - run: | - set -euo pipefail - version="${RELEASE_TAG#v}" - bash "${GITHUB_WORKSPACE}/.github/scripts/publish/aur-prepare.sh" \ - "${{ matrix.pkgname }}" "${version}" + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + env: { PKGNAME: "${{ matrix.pkgname }}" } + with: + script: | + const { default: prepareAurPkgbuild } = await import("${{ github.workspace }}/.github/scripts/publish/aur.mjs"); + await prepareAurPkgbuild({ core, github, context }); - name: publish to AUR - if: env.DRY_RUN != 'true' + if: inputs.dry-run != 'true' uses: KSXGitHub/github-actions-deploy-aur@da03e160361ce01bf087e790b6ffd196d7dccff7 # v4.1.3 with: pkgname: ${{ matrix.pkgname }} @@ -51,6 +42,6 @@ jobs: commit_username: Kaj Kowalski commit_email: info@kajkowalski.nl ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - commit_message: ${{ format('Update to {0}', env.RELEASE_TAG) }} - # -bin already has per-arch sums injected by aur-prepare.sh. + commit_message: Update to ${{ env.RELEASE_TAG }} + # -bin already has per-arch sums injected by aur.mjs. updpkgsums: ${{ matrix.pkgname == 'runner-run' }} diff --git a/.github/workflows/crates-release.yml b/.github/workflows/crates-release.yml index 091d5c2..51c6b6a 100644 --- a/.github/workflows/crates-release.yml +++ b/.github/workflows/crates-release.yml @@ -8,6 +8,8 @@ on: tag: { description: Release tag to publish (e.g. vX.Y.Z), required: true, type: string } dry-run: { description: Dry run only (no upload to crates.io), required: false, default: false, type: boolean } +permissions: { contents: read, id-token: write } + env: RELEASE_TAG: ${{ case(github.event_name == 'workflow_dispatch', inputs.tag, github.ref_name) }} INPUT_DRY_RUN: ${{ inputs.dry-run || 'false' }} @@ -17,8 +19,7 @@ jobs: publish: name: publish to crates.io runs-on: ubuntu-latest - permissions: { contents: read, id-token: write } - environment: { name: crates-io, url: "${{ format('https://crates.io/crates/runner-run/{0}', steps.verify.outputs.version) }}" } + environment: { name: crates-io, url: "https://crates.io/crates/runner-run/${{ steps.verify.outputs.version }}" } steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: { ref: "refs/tags/${{ env.RELEASE_TAG }}", persist-credentials: false } diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 63cea91..9dc2143 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -1,17 +1,11 @@ name: npm-release -run-name: >- - ${{ format('npm publish {0}', - case(github.event_name == 'release', github.event.release.tag_name, - inputs.tag)) }} +run-name: ${{ format('npm publish {0}', case(github.event_name == 'release', github.event.release.tag_name, inputs.tag)) }} on: release: { types: [published] } workflow_dispatch: inputs: - tag: - description: Release tag to publish (e.g. v0.5.0) - required: true - type: string + tag: { description: Release tag to publish (e.g. v0.5.0), required: true, type: string } run-id: description: |- release.yml run ID that produced the npm-dist artifact for this tag. @@ -25,23 +19,12 @@ on: required: false options: [latest, next] type: choice - dry-run: - description: Dry run only - required: true - default: false - type: boolean + dry-run: { description: Dry run only, required: true, default: false, type: boolean } -permissions: - contents: read +permissions: { contents: read } env: - NODE_VERSION: latest - REGISTRY: https://registry.npmjs.org/ - # Trusted trigger metadata only. RELEASE_TAG: ${{ case(github.event_name == 'release', github.event.release.tag_name, inputs.tag) }} - EVENT_NAME: ${{ github.event_name }} - INPUT_DIST_TAG: ${{ inputs.dist-tag }} - INPUT_DRY_RUN: ${{ inputs.dry-run || false }} jobs: publish: @@ -50,10 +33,7 @@ jobs: if: >- github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'v')) - permissions: - contents: read - id-token: write # for npm provenance - actions: read # for cross-workflow artifact download + permissions: { contents: read, id-token: write, actions: read } environment: { name: npm, url: "${{ steps.publish.outputs.package-url }}" } outputs: { dry-run: "${{ steps.derive.outputs.dry-run }}" } steps: @@ -66,59 +46,39 @@ jobs: .github/scripts npm/targets.json sparse-checkout-cone-mode: false - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: ${{ env.NODE_VERSION }} - registry-url: ${{ env.REGISTRY }} + with: { node-version-file: .node-version, registry-url: https://registry.npmjs.org/ } - # release.yml's run is already success-marked by the time - # release:published fires (publish-release is the last job). + # release.yml may self-dispatch this workflow before GitHub has marked + # that run successful; wait here before downloading its artifact. - name: resolve build run-id id: resolve - env: - GH_TOKEN: ${{ github.token }} - INPUT_RUN_ID: ${{ inputs.run-id }} - run: | - set -euo pipefail - if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then - run_id="${INPUT_RUN_ID}" - else - run_id=$(gh run list \ - --repo "${GITHUB_REPOSITORY}" \ - --workflow=release.yml \ - --branch="${RELEASE_TAG}" \ - --status=success \ - --limit=1 \ - --json databaseId \ - --jq '.[0].databaseId') - fi - if [[ -z "${run_id}" || "${run_id}" == "null" ]]; then - echo "error: no run-id resolvable for ${RELEASE_TAG}" >&2 - exit 1 - fi - # Numeric guard — blocks GITHUB_OUTPUT injection. - if ! [[ "${run_id}" =~ ^[0-9]+$ ]]; then - echo "error: run-id '${run_id}' is not a positive integer" >&2 - exit 1 - fi - echo "run-id=${run_id}" >> "${GITHUB_OUTPUT}" + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + retries: 3 + script: | + const { resolveReleaseRun } = await import("${{ github.workspace }}/.github/scripts/publish/npm.mjs"); + await resolveReleaseRun({ core, github, context }); - name: download npm-dist artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: npm-dist - path: npm/dist/ - run-id: ${{ steps.resolve.outputs.run-id }} - github-token: ${{ github.token }} + with: { name: npm-dist, path: npm/dist/, run-id: "${{ steps.resolve.outputs.run-id }}" } - name: derive dist-tag and dry-run id: derive - run: bash "${GITHUB_WORKSPACE}/.github/scripts/build/derive-dist-dry.sh" + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + script: | + const { deriveNpmPublishSettings } = await import("${{ github.workspace }}/.github/scripts/publish/npm.mjs"); + await deriveNpmPublishSettings({ core, context }); - name: publish to npm id: publish env: - DIST_TAG: ${{ steps.derive.outputs.dist-tag }} - DRY_RUN: ${{ steps.derive.outputs.dry-run }} - run: bash "${GITHUB_WORKSPACE}/.github/scripts/publish/npm.sh" + INPUT_DERIVE_OUTPUTS: ${{ toJSON(steps.derive.outputs) }} + INPUT_WORKFLOW_ENV: ${{ toJSON(env) }} + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + script: | + const { default: publishNpmPackages } = await import("${{ github.workspace }}/.github/scripts/publish/npm.mjs"); + await publishNpmPackages({ core, exec }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8437605..36159ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,5 @@ name: release -run-name: >- - ${{ case( - github.event_name == 'pull_request', - format('verify PR #{0}', github.event.pull_request.number), - github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'), - format('release {0}', github.ref_name), - format('verify {0}', github.ref_name) - ) }} +run-name: "${{ case(github.event_name == 'pull_request', format('verify PR {0}{1}', '#', github.event.pull_request.number), github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'), format('release {0}', github.ref_name), format('verify {0}', github.ref_name)) }}" on: { pull_request: null, push: { branches: [master], tags: [v*] } } permissions: { contents: read } env: { CARGO_TERM_COLOR: always } @@ -37,28 +30,17 @@ jobs: - name: create github release uses: taiki-e/create-gh-release-action@eba8ea96c86cca8a37f1b56e94b4d13301fba651 # v1.11.0 - with: - token: ${{ github.token }} - changelog: CHANGELOG.md - draft: true - branch: master + with: { token: "${{ github.token }}", changelog: CHANGELOG.md, draft: true, branch: master } - name: append GitHub auto-generated notes # release:published never fires for token-created releases, so we # assemble the final body here. - env: { GH_TOKEN: "${{ github.token }}", RELEASE_TAG: "${{ github.ref_name }}" } - run: | - set -euo pipefail - our_body="$(gh release view "${RELEASE_TAG}" \ - --repo "${GITHUB_REPOSITORY}" --json body --jq .body)" - gen_body="$(gh api \ - "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ - -f tag_name="${RELEASE_TAG}" --jq .body)" - combined="$(mktemp)" - printf '%s\n\n%s\n' "${our_body}" "${gen_body}" >"${combined}" - gh release edit "${RELEASE_TAG}" \ - --repo "${GITHUB_REPOSITORY}" --notes-file "${combined}" - rm -f "${combined}" + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + retries: 3 + script: | + const { default: appendGeneratedReleaseNotes } = await import("${{ github.workspace }}/.github/scripts/release/notes.mjs"); + await appendGeneratedReleaseNotes({ core, github, context }); man: name: man pages @@ -74,13 +56,11 @@ jobs: - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - run: cargo run --quiet --features man -- man --output man - name: archive and upload to release - run: | - set -euo pipefail - archive="runner-${RELEASE_TAG}-man.tar.gz" - tar -C man -czf "${archive}" . - sha256sum "${archive}" >"runner-${RELEASE_TAG}-man.sha256" - gh release upload "${RELEASE_TAG}" \ - "${archive}" "runner-${RELEASE_TAG}-man.sha256" --clobber + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + script: | + const { default: archiveManPages } = await import("${{ github.workspace }}/.github/scripts/build/man-archive.mjs"); + await archiveManPages({ core, exec }); - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: { name: man-pages, path: man/, if-no-files-found: error, retention-days: 14 } @@ -93,23 +73,29 @@ jobs: outputs: { include: "${{ steps.gen.outputs.include }}" } steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - sparse-checkout: npm/targets.json - sparse-checkout-cone-mode: false - persist-credentials: false + with: { sparse-checkout: npm/targets.json, sparse-checkout-cone-mode: false, persist-credentials: false } - id: gen # `target: .rust` is mandatory — taiki-e and build-packages.ts both # derive the asset filename `runner--.tar.gz` from it. # `vm`-typed targets need a dedicated job; filtered out here. - run: | - include=$(jq -c '[.targets[] | select(.build != "vm") | { - target: .rust, - runner: .runner, - "build-tool": .build, - experimental: (.experimental // false) - }]' npm/targets.json) - echo "include=${include}" >> "${GITHUB_OUTPUT}" + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + script: | + const { readFile } = await import('node:fs/promises'); + + const { targets } = JSON.parse(await readFile('npm/targets.json', 'utf8')); + const include = targets + .filter(({ build }) => build !== 'vm') + .map((target) => ({ + target: target.rust, + runner: target.runner, + 'build-tool': target.build, + experimental: target.experimental ?? false, + })); + + core.setOutput('include', JSON.stringify(include)); + await core.summary.addHeading('Build matrix').addRaw(`Generated ${include.length} release build targets.`).write(); upload-assets: name: build+upload ${{ matrix.target }} @@ -159,24 +145,20 @@ jobs: include: README.md,LICENSE locked: true - # Tier-3: build/archive/upload manually since upload-rust-binary-action - # can't inject `-Z build-std`. + # Tier-3: build/archive/upload manually since upload-rust-binary-action can't inject `-Z build-std`. - name: build (cargo + -Z build-std) if: matrix.build-tool == 'cargo-build-std' - env: { CARGO_UNSTABLE_BUILD_STD: std, panic_abort } - run: | - cargo +nightly build \ - --release --locked \ - --bin runner --bin run \ - --target "${{ matrix.target }}" + env: { CARGO_UNSTABLE_BUILD_STD: "std, panic_abort", T: "${{ matrix.target }}" } + run: cargo +nightly build --release --locked --bin runner --bin run --target "${T}" - name: package and upload (cargo-build-std) if: matrix.build-tool == 'cargo-build-std' - env: - GH_TOKEN: ${{ github.token }} - TARGET: ${{ matrix.target }} - BIN_DIR: ${{ github.workspace }}/target/${{ matrix.target }}/release - run: bash "${GITHUB_WORKSPACE}/.github/scripts/build/package-release-asset.sh" + env: { GH_TOKEN: "${{ github.token }}", TARGET: "${{ matrix.target }}", BIN_DIR: "${{ github.workspace }}/target/${{ matrix.target }}/release" } + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + script: | + const { default: packageReleaseAsset } = await import("${{ github.workspace }}/.github/scripts/build/package-release-asset.mjs"); + await packageReleaseAsset({ core, exec }); build-npm-dist: name: build npm dist @@ -191,22 +173,33 @@ jobs: with: { persist-credentials: false } - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: { node-version: latest } + with: { node-version-file: .node-version } - name: download release archives env: { GH_TOKEN: "${{ github.token }}" } - run: bash "${GITHUB_WORKSPACE}/.github/scripts/build/download-release-archives.sh" + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + script: | + const { default: downloadReleaseArchives } = await import("${{ github.workspace }}/.github/scripts/build/download-release-archives.mjs"); + await downloadReleaseArchives({ core, context, exec }); - name: download man pages uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: { name: man-pages, path: man } - name: verify checksums - working-directory: npm/downloads - run: bash "${GITHUB_WORKSPACE}/.github/scripts/build/verify-checksum.sh" + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + script: | + const { default: verifyChecksums } = await import("${{ github.workspace }}/.github/scripts/build/verify-checksum.mjs"); + await verifyChecksums({ core }); - name: build npm packages - run: bash "${GITHUB_WORKSPACE}/.github/scripts/build/build-npm-packages.sh" + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + script: | + const { default: buildNpmPackages } = await import("${{ github.workspace }}/.github/scripts/build/npm.mjs"); + await buildNpmPackages({ core, context, exec }); - name: smoke-test packaged linux-x64-gnu binary # Only target executable on ubuntu-latest; others exercised at install. @@ -221,19 +214,30 @@ jobs: - name: upload npm/dist artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: npm-dist - path: npm/dist/ - retention-days: 14 - if-no-files-found: error + with: { name: npm-dist, path: npm/dist/, retention-days: 14, if-no-files-found: error } - # Flip draft→published once binaries + npm-dist are in place. Drives - # npm-release.yml via release:published. Idempotent. + # Flip draft→published once binaries + npm-dist are in place. Releases + # published with GITHUB_TOKEN do not emit downstream release:published + # workflow runs, so explicitly dispatch package publishers afterward. publish-release: name: publish github release needs: [build-npm-dist] if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest - permissions: { contents: write } - env: { GH_TOKEN: "${{ github.token }}", RELEASE_TAG: "${{ github.ref_name }}" } - steps: [{ run: 'gh release edit "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --draft=false' }] + permissions: { actions: write, contents: write } + steps: + - name: publish github release + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + retries: 3 + script: | + const { publishGithubRelease } = await import("${{ github.workspace }}/.github/scripts/release/publish.mjs"); + await publishGithubRelease({ core, github, context }); + + - name: dispatch package publishes + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + retries: 3 + script: | + const { dispatchPackagePublishes } = await import("${{ github.workspace }}/.github/scripts/release/publish.mjs"); + await dispatchPackagePublishes({ core, github, context }); diff --git a/.gitignore b/.gitignore index 9890040..ef414eb 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ node_modules/ # serena MCP cache /.serena/ /proposal.md + +# build scripts +!.github/scripts/build +!.cargo diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..84a2f4a --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +v26.3.0 diff --git a/bun.lock b/bun.lock index 421384d..6cea9e1 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "workspace", "devDependencies": { + "@actions/github-script": "github:actions/github-script#373c709c69115d41ff229c7e5df9f8788daa9553", "@types/node": ">=22.18.0", "typescript": "^5", }, @@ -28,6 +29,20 @@ }, }, "packages": { + "@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="], + + "@actions/exec": ["@actions/exec@1.1.1", "", { "dependencies": { "@actions/io": "^1.0.1" } }, "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w=="], + + "@actions/github": ["@actions/github@9.1.1", "", { "dependencies": { "@actions/http-client": "^3.0.2", "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0", "@octokit/request": "^10.0.7", "@octokit/request-error": "^7.1.0", "undici": "^6.23.0" } }, "sha512-tL5JbYOBZHc0ngEnCsaDcryUizIUIlQyIMwy1Wkx93H5HzbBJ7TbiPx2PnFjBwZW0Vh05JmfFZhecE6gglYegA=="], + + "@actions/github-script": ["@actions/github-script@github:actions/github-script#373c709", { "dependencies": { "@actions/core": "^1.10.1", "@actions/exec": "^1.1.1", "@actions/github": "^9.0.0", "@actions/glob": "^0.4.0", "@actions/io": "^1.1.3", "@octokit/core": "^7.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-retry": "^8.0.0", "@types/node": "^24.1.0" } }, "actions-github-script-373c709", "sha512-HXVBCEYln6cEb3Xzi7x21+aZZe1ev0uaCV21NqSOgwFMMX1nPzNFEWGITu4yYUNcC+iuHqW7jsg488KM0/b8Rg=="], + + "@actions/glob": ["@actions/glob@0.4.0", "", { "dependencies": { "@actions/core": "^1.9.1", "minimatch": "^3.0.4" } }, "sha512-+eKIGFhsFa4EBwaf/GMyzCdWrXWymGXfFmZU3FHQvYS8mPcHtTtZONbkcqqUMzw9mJ/pImEBFET1JNifhqGsAQ=="], + + "@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + + "@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="], + "@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="], @@ -116,6 +131,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -172,6 +189,30 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], + + "@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], + + "@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + + "@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], + + "@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="], + + "@octokit/plugin-retry": ["@octokit/plugin-retry@8.1.0", "", { "dependencies": { "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": ">=7" } }, "sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw=="], + + "@octokit/request": ["@octokit/request@10.0.10", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w=="], + + "@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], @@ -186,10 +227,22 @@ "@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], + + "brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -200,10 +253,14 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "json-with-bigint": ["json-with-bigint@3.5.8", "", {}, "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "miniflare": ["miniflare@4.20260515.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260515.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-2j0oQWizk1Eu4Cm8tDX7Z+Nsjd0nebIj1TQcQ+Oy1QKeo0Ay9+bdn8wfLAtOj9znDCybDCUlnS1+nYvKXEdfNg=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -220,14 +277,18 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], + "undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + "workerd": ["workerd@1.20260515.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260515.1", "@cloudflare/workerd-darwin-arm64": "1.20260515.1", "@cloudflare/workerd-linux-64": "1.20260515.1", "@cloudflare/workerd-linux-arm64": "1.20260515.1", "@cloudflare/workerd-windows-64": "1.20260515.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-MjKOJLcvU45xXedQowvuiHtJTxu4WTHYQeIlF7YmjuqhiI6dImTFxWCEoRQHiskztxuVSNEmdO7/0UfDu6OMnQ=="], "wrangler": ["wrangler@4.92.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260515.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260515.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260515.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-/DKpQHPxkuZbQsO9dFW2700VTD/4DSZMHjy92fO/frNoDRi/zQsFCAd2ONCV6TGqcUoXcP3D8Bo2gj/L4M0qQQ=="], @@ -238,6 +299,12 @@ "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "@actions/github/@actions/http-client": ["@actions/http-client@3.0.2", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^6.23.0" } }, "sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA=="], + + "@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + + "miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], + "runner-site/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], } } diff --git a/package.json b/package.json index 2680d4a..dd41eea 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,16 @@ "site", "npm/facade" ], - "scripts": { "fmt": "bunx dprint fmt" }, + "scripts": { + "fmt": "bunx dprint fmt" + }, "devDependencies": { + "@actions/github-script": "github:actions/github-script#373c709c69115d41ff229c7e5df9f8788daa9553", "@types/node": ">=22.18.0", "typescript": "^5" }, "volta": { - "node": "24.14.1", - "npm": "11.6.2" + "node": "26.3.0", + "npm": "11.16.0" } } diff --git a/tsconfig.json b/tsconfig.json index a0c234f..53d11a8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,6 @@ "resolveJsonModule": true, "typeRoots": ["./npm/facade/node_modules/@types"] }, - "include": ["npm/**/*", "action.mjs"], + "include": ["npm", "action.mjs", ".github/scripts"], "exclude": ["node_modules", "dist"] }