Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .github/scripts/release/package-node.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import fs from "node:fs";
import path from "node:path";

import { runNpm } from "./utils.mjs";

const bindingsDirectory = path.resolve("bindings/js");
const nativePackagesDirectory = path.join(bindingsDirectory, "npm");
const outputDirectory = path.resolve("package-artifacts/npm");
const packagePath = path.join(bindingsDirectory, "package.json");
const readmePath = path.join(bindingsDirectory, "README.md");

const nativePackages = fs
.readdirSync(nativePackagesDirectory, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(nativePackagesDirectory, entry.name))
.sort();

if (nativePackages.length === 0) {
throw new Error(`No native packages found in ${nativePackagesDirectory}`);
}

fs.mkdirSync(outputDirectory, { recursive: true });

for (const nativePackage of nativePackages) {
const hasNativeAddon = fs
.readdirSync(nativePackage, { withFileTypes: true })
.some((entry) => entry.isFile() && entry.name.endsWith(".node"));
if (!hasNativeAddon) {
throw new Error(`Missing native addon in ${nativePackage}`);
}

runNpm([
"pack",
nativePackage,
"--pack-destination",
outputDirectory,
]);
}

const originalPackage = fs.readFileSync(packagePath, "utf8");
const originalReadme = fs.readFileSync(readmePath, "utf8");
const rootPackage = JSON.parse(originalPackage);
rootPackage.optionalDependencies = {};

for (const nativePackage of nativePackages) {
const manifest = JSON.parse(
fs.readFileSync(path.join(nativePackage, "package.json"), "utf8"),
);
rootPackage.optionalDependencies[manifest.name] = manifest.version;
}

try {
fs.writeFileSync(
packagePath,
`${JSON.stringify(rootPackage, null, 2)}\n`,
);
runNpm([
"pack",
bindingsDirectory,
"--ignore-scripts",
"--pack-destination",
outputDirectory,
]);

rootPackage.name = "shell-use";
fs.writeFileSync(
packagePath,
`${JSON.stringify(rootPackage, null, 2)}\n`,
);
fs.writeFileSync(
readmePath,
originalReadme.replaceAll("@microsoft/shell-use", "shell-use"),
);
runNpm([
"pack",
bindingsDirectory,
"--ignore-scripts",
"--pack-destination",
outputDirectory,
]);
} finally {
fs.writeFileSync(packagePath, originalPackage);
fs.writeFileSync(readmePath, originalReadme);
}
57 changes: 57 additions & 0 deletions .github/scripts/release/publish-npm.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
listPackageTarballs,
readPackageManifest,
runNpm,
spawnNpm,
} from "./utils.mjs";

const packages = listPackageTarballs("npm-packages").map((tarball) => ({
tarball,
manifest: readPackageManifest(tarball),
}));

if (packages.length < 10) {
throw new Error("Expected eight native packages and two root packages");
}

function publishIfMissing({ tarball, manifest }) {
const packageVersion = `${manifest.name}@${manifest.version}`;
const result = spawnNpm(["view", packageVersion, "version"], {
stdio: "ignore",
});

if (result.error) {
throw result.error;
}
if (result.status === 0) {
console.log(`${packageVersion} is already published`);
return;
}
if (result.signal) {
throw new Error(
`npm view ${packageVersion} terminated with ${result.signal}`,
);
}

runNpm([
"publish",
tarball,
"--access",
"public",
"--provenance",
"--tag",
"latest",
]);
}

const nativePackages = packages.filter(({ manifest }) =>
manifest.name.startsWith("@microsoft/shell-use-"),
);
const rootPackages = packages.filter(
({ manifest }) =>
manifest.name === "@microsoft/shell-use" || manifest.name === "shell-use",
);

for (const packageArtifact of [...nativePackages, ...rootPackages]) {
publishIfMissing(packageArtifact);
}
54 changes: 54 additions & 0 deletions .github/scripts/release/smoke-node.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";

import {
listPackageTarballs,
readPackageManifest,
runNpm,
} from "./utils.mjs";

const packages = listPackageTarballs("npm-packages").map((tarball) => ({
tarball,
manifest: readPackageManifest(tarball),
}));

function findPackage(name) {
const matches = packages.filter(({ manifest }) => manifest.name === name);
if (matches.length !== 1) {
throw new Error(`Expected one ${name} package, found ${matches.length}`);
}
return matches[0].tarball;
}

const rootPackage = findPackage("@microsoft/shell-use");
const platformPackage = findPackage("@microsoft/shell-use-linux-x64-gnu");
const smokeDirectory = path.resolve("smoke");

fs.mkdirSync(smokeDirectory);
runNpm(["init", "-y"], {
cwd: smokeDirectory,
stdio: ["ignore", "ignore", "inherit"],
});
runNpm(["install", "--ignore-scripts", platformPackage], {
cwd: smokeDirectory,
});
runNpm(
["install", "--ignore-scripts", "--omit=optional", rootPackage],
{ cwd: smokeDirectory },
);

const requireFromSmoke = createRequire(path.join(smokeDirectory, "package.json"));
const packageEntry = requireFromSmoke.resolve("@microsoft/shell-use");
const { ShellUse } = await import(pathToFileURL(packageEntry).href);

const session = ShellUse.ephemeral("release-smoke");
try {
await session.open();
await session.submit("echo release-smoke");
await session.waitCommand();
await session.expectText("release-smoke", { strict: false });
} finally {
await session.closeQuiet();
}
52 changes: 52 additions & 0 deletions .github/scripts/release/smoke-python.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import asyncio
import os
import subprocess
import sys
import venv
from pathlib import Path


async def smoke_test():
from shell_use import ShellUse

async with ShellUse.ephemeral("release-smoke") as session:
await session.open()
await session.submit("echo release-smoke")
await session.wait_command()
await session.expect_text("release-smoke", strict=False)


def main():
if "--run-smoke" in sys.argv:
asyncio.run(smoke_test())
return

wheels = sorted(Path("dist").glob("*.whl"))
if not wheels:
raise RuntimeError("No Python wheels found in dist")

smoke_directory = Path("smoke")
venv.create(smoke_directory, with_pip=True)
python = smoke_directory / (
"Scripts/python.exe" if os.name == "nt" else "bin/python"
)

subprocess.run(
[
python,
"-m",
"pip",
"install",
"--disable-pip-version-check",
*wheels,
],
check=True,
)
subprocess.run(
[python, Path(__file__).resolve(), "--run-smoke"],
check=True,
)


if __name__ == "__main__":
main()
49 changes: 49 additions & 0 deletions .github/scripts/release/utils.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";

export function run(command, args, options = {}) {
const { stdio = "inherit", ...rest } = options;
execFileSync(command, args, { ...rest, stdio });
Comment thread
cpendery marked this conversation as resolved.
Dismissed
}

function npmInvocation(args) {
if (process.platform === "win32") {
return {
command: process.env.ComSpec ?? "cmd.exe",
args: ["/d", "/s", "/c", "npm", ...args],
};
}
return { command: "npm", args };
}

export function runNpm(args, options = {}) {
const invocation = npmInvocation(args);
run(invocation.command, invocation.args, options);
}

export function spawnNpm(args, options = {}) {
const invocation = npmInvocation(args);
return spawnSync(invocation.command, invocation.args, options);
Comment thread
cpendery marked this conversation as resolved.
Dismissed
}

export function listPackageTarballs(directory) {
return fs
.readdirSync(directory, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".tgz"))
.map((entry) => path.resolve(directory, entry.name))
.sort();
}

export function readPackageManifest(tarball) {
const absoluteTarball = path.resolve(tarball);
const contents = execFileSync(
"tar",
["-xOf", path.basename(absoluteTarball), "package/package.json"],
{
cwd: path.dirname(absoluteTarball),
encoding: "utf8",
},
);
return JSON.parse(contents);
}
31 changes: 31 additions & 0 deletions .github/scripts/release/verify-versions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import fs from "node:fs";

const releaseTag = process.env.RELEASE_TAG;
if (!releaseTag) {
throw new Error("RELEASE_TAG is required");
}

const expected = releaseTag.replace(/^v/, "");
const jsPackage = JSON.parse(
fs.readFileSync("bindings/js/package.json", "utf8"),
);
const versions = {
"Cargo.toml": fs.readFileSync("Cargo.toml", "utf8").match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1],
"bindings/js/package.json": jsPackage.version,
"bindings/js/src/version.ts": fs.readFileSync("bindings/js/src/version.ts", "utf8").match(/VERSION\s*=\s*"([^"]+)"/)?.[1],
"bindings/python/pyproject.toml": fs.readFileSync("bindings/python/pyproject.toml", "utf8").match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1],
"bindings/python/src/shell_use/_config.py": fs.readFileSync("bindings/python/src/shell_use/_config.py", "utf8").match(/VERSION\s*=\s*"([^"]+)"/)?.[1],
};

for (const [file, version] of Object.entries(versions)) {
if (version !== expected) {
throw new Error(`${file} has version ${version}; expected ${expected}`);
}
}

const nativeLoader = fs.readFileSync("bindings/js/native/index.js", "utf8");
if (!nativeLoader.includes(`'${expected}'`)) {
throw new Error(
`bindings/js/native/index.js was not regenerated for ${expected}`,
);
}
Loading
Loading