diff --git a/.github/workflows/turborepo-release.yml b/.github/workflows/turborepo-release.yml index d256109fc29bf..8fc3713ef5590 100644 --- a/.github/workflows/turborepo-release.yml +++ b/.github/workflows/turborepo-release.yml @@ -223,15 +223,6 @@ jobs: git config --global user.name 'Turbobot' git config --global user.email 'turbobot@vercel.com' - - name: Install GoReleaser - uses: goreleaser/goreleaser-action@v6 - with: - distribution: goreleaser-pro - version: v1.18.2 - install-only: true - env: - GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} - - name: Download Rust artifacts uses: actions/download-artifact@v4 with: @@ -242,14 +233,13 @@ jobs: mv rust-artifacts/turbo-aarch64-apple-darwin cli/dist-darwin-arm64 mv rust-artifacts/turbo-aarch64-unknown-linux-musl cli/dist-linux-arm64 cp -r rust-artifacts/turbo-x86_64-pc-windows-msvc cli/dist-windows-arm64 - mv rust-artifacts/turbo-x86_64-unknown-linux-musl cli/dist-linux-amd64 - mv rust-artifacts/turbo-x86_64-apple-darwin cli/dist-darwin-amd64 - mv rust-artifacts/turbo-x86_64-pc-windows-msvc cli/dist-windows-amd64 + mv rust-artifacts/turbo-x86_64-unknown-linux-musl cli/dist-linux-x64 + mv rust-artifacts/turbo-x86_64-apple-darwin cli/dist-darwin-x64 + mv rust-artifacts/turbo-x86_64-pc-windows-msvc cli/dist-windows-x64 - name: Perform Release run: cd cli && make publish-turbo SKIP_PUBLISH=${{ inputs.dry_run && '--skip-publish' || '' }} env: - GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # Upload published artifacts in case they are needed for debugging later diff --git a/cli/Makefile b/cli/Makefile index 77658cbf29ea5..b5d56caad51d6 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -54,8 +54,7 @@ publish-turbo: build npm config set --location=project "//registry.npmjs.org/:_authToken" $(NPM_TOKEN) # Publishes the native npm modules. - # TODO: do this without goreleaser. - goreleaser release --rm-dist -f combined-shim.yml $(SKIP_PUBLISH) + turbo release-native -- $(SKIP_PUBLISH) # Split packing from the publish step so that npm locates the correct .npmrc file. cd $(CLI_DIR)/../packages/turbo && pnpm pack --pack-destination=$(CLI_DIR)/../ diff --git a/cli/combined-shim.yml b/cli/combined-shim.yml deleted file mode 100644 index 67d9914a3269c..0000000000000 --- a/cli/combined-shim.yml +++ /dev/null @@ -1,78 +0,0 @@ -project_name: turbo - -dist: dist - -builds: - - id: turbo - builder: prebuilt - tags: - - rust - - staticbinary - goos: - - linux - - windows - - darwin - goarch: - - amd64 - - arm64 - goamd64: - - v1 - prebuilt: - path: dist-{{ .Os }}-{{ .Arch }}/turbo{{ .Ext }} - hooks: - pre: - - cmd: ./scripts/npm-native-packages/npm-native-packages.js {{ .Os }} {{ .Arch }} {{ .Version }} - binary: bin/turbo -checksum: - name_template: "checksums.txt" -snapshot: - name_template: "{{ incpatch .Version }}" -archives: - - id: github - name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}" - wrap_in_directory: true - replacements: - amd64: 64 - format: tar.gz - format_overrides: - - goos: windows - format: zip - files: - - LICENSE - - README.md - - id: npm - name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" - wrap_in_directory: true - replacements: - amd64: 64 - format: tar.gz - files: - - LICENSE - - src: "scripts/npm-native-packages/build/{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}/package.json" - dst: "workaround/.." - strip_parent: true - - src: "scripts/npm-native-packages/build/{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}/README.md" - dst: "workaround/.." - strip_parent: true - - src: "scripts/npm-native-packages/build/{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}/bin/*" - dst: "bin/" - strip_parent: true -changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" -release: - github: - owner: vercel - name: turborepo - ids: - - github - prerelease: auto - disable: true -publishers: - - name: npm - ids: - - npm - cmd: "npm publish{{ if .Prerelease }} --tag canary{{ end }} {{ abs .ArtifactPath }}" diff --git a/cli/package.json b/cli/package.json index deff9108f9185..197d0c130731e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -5,6 +5,10 @@ "scripts": { "clean": "cargo clean --package turbo", "build": "cargo build --package turbo", - "build:release": "cargo build --package turbo --profile release-turborepo" + "build:release": "cargo build --package turbo --profile release-turborepo", + "release-native": "turboreleaser --version-path ../version.txt" + }, + "dependencies": { + "@turbo/releaser": "workspace:*" } } diff --git a/cli/scripts/npm-native-packages/.gitignore b/cli/scripts/npm-native-packages/.gitignore deleted file mode 100644 index 84c048a73cc2e..0000000000000 --- a/cli/scripts/npm-native-packages/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build/ diff --git a/cli/scripts/npm-native-packages/npm-native-packages.js b/cli/scripts/npm-native-packages/npm-native-packages.js deleted file mode 100755 index 52b7463b40d1b..0000000000000 --- a/cli/scripts/npm-native-packages/npm-native-packages.js +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node - -const fs = require("fs"); -const path = require("path"); - -// Map to node os and arch names. -const nodeOsLookup = { - darwin: "darwin", - linux: "linux", - windows: "win32", -}; - -const nodeArchLookup = { - amd64: "x64", - arm64: "arm64", -}; - -const humanizedArchLookup = { - amd64: "64", - arm64: "arm64", -}; - -const template = require("./template/template.package.json"); -const os = process.argv[2]; -const arch = process.argv[3]; -const version = process.argv[4]; - -template.name = `turbo-${os}-${humanizedArchLookup[arch]}`; -template.description = `The ${os}-${humanizedArchLookup[arch]} binary for turbo, a monorepo build system.`; -template.os = [nodeOsLookup[os]]; -template.cpu = [nodeArchLookup[arch]]; -template.version = version; - -const outputPath = path.join(__dirname, "build", template.name); -fs.rmSync(outputPath, { recursive: true, force: true }); -fs.mkdirSync(path.join(outputPath, "bin"), { recursive: true }); - -if (os === "windows") { - fs.copyFileSync( - path.join(__dirname, "template", "bin", "turbo"), - path.join(outputPath, "bin", "turbo") - ); -} -fs.copyFileSync( - path.join(__dirname, "template", "README.md"), - path.join(outputPath, "README.md") -); -fs.writeFileSync( - path.join(outputPath, "package.json"), - JSON.stringify(template, null, 2) -); diff --git a/cli/scripts/npm-native-packages/template/template.package.json b/cli/scripts/npm-native-packages/template/template.package.json deleted file mode 100644 index c6f9700b183e7..0000000000000 --- a/cli/scripts/npm-native-packages/template/template.package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "turbo-{{Os}}-{{Arch}}", - "version": "{{Version}", - "description": "The {{Os}}-{{Arch}} binary for turbo, a monorepo build system.", - "repository": "https://github.com/vercel/turborepo", - "bugs": "https://github.com/vercel/turborepo/issues", - "homepage": "https://turbo.build/repo", - "license": "MIT", - "os": ["{{Os}}"], - "cpu": ["{{Arch}}"], - "preferUnplugged": true -} diff --git a/cli/turbo.json b/cli/turbo.json index 92b936b9c0dcc..9e1e5aa12ac4a 100644 --- a/cli/turbo.json +++ b/cli/turbo.json @@ -39,6 +39,10 @@ "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY" ] + }, + "release-native": { + "dependsOn": ["@turbo/releaser#build"], + "cache": false } } } diff --git a/packages/turbo-releaser/.eslintrc.js b/packages/turbo-releaser/.eslintrc.js new file mode 100644 index 0000000000000..1ea2c1f97c3c6 --- /dev/null +++ b/packages/turbo-releaser/.eslintrc.js @@ -0,0 +1,26 @@ +module.exports = { + extends: ["@turbo/eslint-config/library"], + overrides: [ + { + files: ["src/*.ts", "cli/index.cjs"], + rules: { + "no-console": "off", + }, + }, + { + files: ["src/native.ts", "src/operations.ts"], + rules: { + "import/no-default-export": "off", + }, + }, + { + files: ["src/*.test.ts"], + rules: { + // https://github.com/nodejs/node/issues/51292 + "@typescript-eslint/no-floating-promises": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-argument": "off", + }, + }, + ], +}; diff --git a/packages/turbo-releaser/cli/index.cjs b/packages/turbo-releaser/cli/index.cjs new file mode 100755 index 0000000000000..d635b1dc6e50c --- /dev/null +++ b/packages/turbo-releaser/cli/index.cjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +const { spawnSync } = require("node:child_process"); +const path = require("node:path"); + +const PATH_TO_DIST = path.resolve(__dirname, "../dist"); + +// Define the path to the CLI file +const cliPath = path.resolve(__dirname, PATH_TO_DIST, "index.js"); + +try { + const result = spawnSync("node", [cliPath, ...process.argv.slice(2)], { + stdio: "inherit", + }); + + process.exit(result.status); +} catch (error) { + console.error("Error loading turboreleaser CLI, please re-install", error); + process.exit(1); +} diff --git a/packages/turbo-releaser/package.json b/packages/turbo-releaser/package.json new file mode 100644 index 0000000000000..6917aaa0b75fd --- /dev/null +++ b/packages/turbo-releaser/package.json @@ -0,0 +1,32 @@ +{ + "name": "@turbo/releaser", + "private": true, + "version": "0.0.1", + "bin": { + "turboreleaser": "cli/index.cjs" + }, + "files": [ + "dist", + "template" + ], + "scripts": { + "build": "tsup", + "check-types": "tsc --noEmit", + "test": "node --import tsx --test src/*.test.ts", + "lint": "eslint src/", + "lint:prettier": "prettier -c . --cache --ignore-path=../../.prettierignore" + }, + "dependencies": { + "commander": "^11.0.0", + "tar": "6.1.13" + }, + "devDependencies": { + "@turbo/eslint-config": "workspace:*", + "@turbo/tsconfig": "workspace:*", + "@types/node": "^20", + "@types/tar": "^6.1.4", + "typescript": "5.5.4", + "tsup": "^6.7.0", + "tsx": "4.19.1" + } +} diff --git a/packages/turbo-releaser/src/e2e.test.ts b/packages/turbo-releaser/src/e2e.test.ts new file mode 100644 index 0000000000000..1682ef2baf514 --- /dev/null +++ b/packages/turbo-releaser/src/e2e.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import path from "node:path"; +import { tmpdir, arch as osArch, platform } from "node:os"; +import { mkdir, realpath, rm, writeFile } from "node:fs/promises"; +import { execSync } from "node:child_process"; +import operations from "./operations"; +import type { SupportedOS, SupportedArch } from "./types"; + +test("produces installable archive", async () => { + const tempDir = path.join(await realpath(tmpdir()), "turboreleaser-e2e-test"); + await rm(tempDir, { recursive: true, force: true }); + await mkdir(tempDir, { recursive: true }); + + // Need to match actual values otherwise npm will refuse to run + const os = platform() as SupportedOS; + const arch = osArch() as SupportedArch; + + // make a fake turbo binary + const platformPath = `dist-${os}-${arch}`; + await mkdir(path.join(tempDir, platformPath)); + await writeFile( + path.join(tempDir, platformPath, "turbo"), + "#!/bin/bash\necho Invoked fake turbo!" + ); + + const tarPath = await operations.packPlatform({ + platform: { os, arch }, + version: "0.1.2", + srcDir: tempDir, + }); + assert.ok(path.isAbsolute(tarPath)); + + // Make a fake repo to install the tarball in + const fakeRepo = path.join(tempDir, "fake-repo"); + await mkdir(fakeRepo); + await writeFile( + path.join(fakeRepo, "package.json"), + JSON.stringify({ + name: "fake-repo", + scripts: { "test-turbo-install": "turbo" }, + }) + ); + execSync(`npm install ${tarPath}`, { cwd: fakeRepo }); + const output = execSync("npm run test-turbo-install", { + stdio: "pipe", + cwd: fakeRepo, + encoding: "utf-8", + }); + assert.equal( + output, + "\n> test-turbo-install\n> turbo\n\nInvoked fake turbo!\n" + ); +}); diff --git a/packages/turbo-releaser/src/index.ts b/packages/turbo-releaser/src/index.ts new file mode 100644 index 0000000000000..7b4e01d5c8516 --- /dev/null +++ b/packages/turbo-releaser/src/index.ts @@ -0,0 +1,45 @@ +import { Command } from "commander"; +import { packAndPublish } from "./packager"; +import type { Platform } from "./types"; +import { getVersionInfo } from "./version"; + +const supportedPlatforms: Array = [ + { os: "darwin", arch: "x64" }, + { os: "darwin", arch: "arm64" }, + { os: "linux", arch: "x64" }, + { os: "linux", arch: "arm64" }, + { os: "windows", arch: "x64" }, + { os: "windows", arch: "arm64" }, +]; + +const turboReleaser = new Command(); +turboReleaser + .requiredOption("--version-path ", "Path to the version.txt file") + .option("--skip-publish", "Skip publishing to NPM") + .action(main); + +async function main(options: { skipPublish: boolean; versionPath: string }) { + console.log("Command line options:", options); + console.log("Supported platforms:", supportedPlatforms); + + try { + const { version, npmTag } = await getVersionInfo(options.versionPath); + console.log(`Using version: ${version}, NPM tag: ${npmTag}`); + + await packAndPublish({ + platforms: supportedPlatforms, + version, + skipPublish: options.skipPublish as boolean, + npmTag, + }); + console.log("Packaging and publishing completed successfully"); + } catch (error) { + console.error("Error during packaging and publishing:", error); + process.exit(1); + } +} + +turboReleaser.parseAsync().catch((reason) => { + console.error("Unexpected error. Please report it as a bug:", reason); + process.exit(1); +}); diff --git a/packages/turbo-releaser/src/native.test.ts b/packages/turbo-releaser/src/native.test.ts new file mode 100644 index 0000000000000..e4c100cc09812 --- /dev/null +++ b/packages/turbo-releaser/src/native.test.ts @@ -0,0 +1,138 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import fs from "node:fs/promises"; +import native from "./native"; +import type { Platform } from "./types"; + +describe("generateNativePackage", () => { + const outputDir = "/path/to/output"; + + it("should generate package correctly for non-Windows platform", async (t) => { + const mockRm = mock.fn((_path: string) => Promise.resolve()); + const mockMkdir = mock.fn((_path: string) => Promise.resolve()); + const mockCopyFile = mock.fn((_src: string, _dst: string) => + Promise.resolve() + ); + const mockWriteFile = mock.fn((_path: string, _data: string) => + Promise.resolve() + ); + + t.mock.method(fs, "rm", mockRm); + t.mock.method(fs, "mkdir", mockMkdir); + t.mock.method(fs, "copyFile", mockCopyFile); + t.mock.method(fs, "writeFile", mockWriteFile); + + const platform: Platform = { os: "darwin", arch: "x64" }; + const version = "1.0.0"; + await native.generateNativePackage({ platform, version, outputDir }); + + // Assert rm was called correctly + assert.equal(mockRm.mock.calls.length, 1); + assert.equal(mockRm.mock.calls[0].arguments[0], outputDir); + + // Assert mkdir was called correctly + assert.equal(mockMkdir.mock.calls.length, 1); + assert.equal( + mockMkdir.mock.calls[0].arguments[0], + path.join(outputDir, "bin") + ); + + // Assert copyFile was called correctly + assert.equal(mockCopyFile.mock.calls.length, 2); + assert.ok( + mockCopyFile.mock.calls[0].arguments[0].endsWith("template/README.md") + ); + assert.equal( + mockCopyFile.mock.calls[0].arguments[1], + path.join(outputDir, "README.md") + ); + assert.ok( + mockCopyFile.mock.calls[1].arguments[0].endsWith("template/LICENSE") + ); + assert.equal( + mockCopyFile.mock.calls[1].arguments[1], + path.join(outputDir, "LICENSE") + ); + + // Assert writeFile was called correctly + assert.equal(mockWriteFile.mock.calls.length, 1); + const [filePath, content] = mockWriteFile.mock.calls[0].arguments; + assert.equal(filePath, path.join(outputDir, "package.json")); + + const packageJson = JSON.parse(content) as { + name: string; + version: string; + description: string; + os: Array; + cpu: Array; + }; + assert.equal(packageJson.name, `turbo-darwin-${native.archToHuman.x64}`); + assert.equal(packageJson.version, version); + assert.equal( + packageJson.description, + "The darwin-x64 binary for turbo, a monorepo build system." + ); + assert.deepEqual(packageJson.os, ["darwin"]); + assert.deepEqual(packageJson.cpu, ["x64"]); + }); + + it("should handle Windows platform correctly", async (t) => { + const mockRm = mock.fn((_path: string) => Promise.resolve()); + const mockMkdir = mock.fn((_path: string) => Promise.resolve()); + const mockCopyFile = mock.fn((_src: string, _dst: string) => + Promise.resolve() + ); + const mockWriteFile = mock.fn((_path: string, _data: string) => + Promise.resolve() + ); + + t.mock.method(fs, "rm", mockRm); + t.mock.method(fs, "mkdir", mockMkdir); + t.mock.method(fs, "copyFile", mockCopyFile); + t.mock.method(fs, "writeFile", mockWriteFile); + + await native.generateNativePackage({ + platform: { os: "windows", arch: "x64" }, + version: "1.0.0", + outputDir, + }); + + assert.equal(mockCopyFile.mock.calls.length, 3); + assert.ok( + mockCopyFile.mock.calls[0].arguments[0].endsWith("template/bin/turbo") + ); + assert.equal( + mockCopyFile.mock.calls[0].arguments[1], + path.join(outputDir, "bin", "turbo") + ); + const actualPackageJsonContents = mockWriteFile.mock.calls[0].arguments[1]; + const actualPackageJson = JSON.parse(actualPackageJsonContents) as { + os: Array; + }; + assert.equal(actualPackageJson.os[0], "win32"); + }); + + it("should propagate errors", async (t) => { + const mockRm = mock.fn(() => { + throw new Error("Failed to remove directory"); + }); + t.mock.method(fs, "rm", mockRm); + + await assert.rejects( + native.generateNativePackage({ + platform: { os: "linux", arch: "x64" }, + version: "1.2.0", + outputDir, + }), + { message: "Failed to remove directory" } + ); + }); +}); + +describe("archToHuman", () => { + it("should map architectures correctly", () => { + assert.equal(native.archToHuman.x64, "64"); + assert.equal(native.archToHuman.arm64, "arm64"); + }); +}); diff --git a/packages/turbo-releaser/src/native.ts b/packages/turbo-releaser/src/native.ts new file mode 100644 index 0000000000000..44d87f29192e2 --- /dev/null +++ b/packages/turbo-releaser/src/native.ts @@ -0,0 +1,78 @@ +import { rm, mkdir, copyFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { + SupportedArch, + HumanArch, + Platform, + SupportedOS, + NpmOs, +} from "./types"; + +export const archToHuman: Record = { + x64: "64", + arm64: "arm64", +}; + +export const nodeOSLookup: Record = { + darwin: "darwin", + linux: "linux", + windows: "win32", +}; + +const templateDir = path.join(__dirname, "..", "template"); + +async function generateNativePackage({ + platform, + version, + outputDir, +}: { + platform: Platform; + version: string; + outputDir: string; +}) { + const { os, arch } = platform; + console.log(`Generating native package for ${os}-${arch}...`); + + console.log(`Cleaning output directory: ${outputDir}`); + await rm(outputDir, { recursive: true, force: true }); + await mkdir(path.join(outputDir, "bin"), { recursive: true }); + + const copyFromTemplate = async (part: string, ...parts: Array) => { + console.log("Copying ", path.join(part, ...parts)); + await copyFile( + path.join(templateDir, part, ...parts), + path.join(outputDir, part, ...parts) + ); + }; + + if (os === "windows") { + await copyFromTemplate("bin", "turbo"); + } + + await copyFromTemplate("README.md"); + await copyFromTemplate("LICENSE"); + + console.log("Generating package.json..."); + const packageJson = { + name: `turbo-${os}-${archToHuman[arch]}`, + version, + description: `The ${os}-${arch} binary for turbo, a monorepo build system.`, + repository: "https://github.com/vercel/turborepo", + bugs: "https://github.com/vercel/turborepo/issues", + homepage: "https://turbo.build/repo", + license: "MIT", + os: [nodeOSLookup[os]], + cpu: [arch], + preferUnplugged: true, + }; + await writeFile( + path.join(outputDir, "package.json"), + JSON.stringify(packageJson, null, 2) + ); + + console.log(`Native package generated successfully in ${outputDir}`); +} + +// Exported asn an object instead of export keyword, so that these functions +// can be mocked in tests. +export default { generateNativePackage, archToHuman }; diff --git a/packages/turbo-releaser/src/operations.test.ts b/packages/turbo-releaser/src/operations.test.ts new file mode 100644 index 0000000000000..153aa63f5efab --- /dev/null +++ b/packages/turbo-releaser/src/operations.test.ts @@ -0,0 +1,83 @@ +import { describe, it, mock } from "node:test"; +import fs from "node:fs/promises"; +import assert from "node:assert"; +import path from "node:path"; +import tar from "tar"; +import native from "./native"; +import type { Platform } from "./types"; +import operations from "./operations"; + +describe("packPlatform", () => { + it("should pack a platform correctly", async (t) => { + const mockGenerateNativePackage = mock.fn(); + const mockMkdir = mock.fn(); + const mockCopyFile = mock.fn(); + const mockStat = mock.fn(() => Promise.resolve({ mode: 0 })); + const mockChmod = mock.fn(); + const mockTarCreate = mock.fn(); + + t.mock.method(native, "generateNativePackage", mockGenerateNativePackage); + t.mock.method(fs, "mkdir", mockMkdir); + t.mock.method(fs, "copyFile", mockCopyFile); + t.mock.method(fs, "stat", mockStat); + t.mock.method(fs, "chmod", mockChmod); + t.mock.method(tar, "create", mockTarCreate); + + const platform: Platform = { os: "darwin", arch: "x64" }; + const version = "1.0.0"; + + const result = await operations.packPlatform({ platform, version }); + + assert.equal(mockGenerateNativePackage.mock.calls.length, 1); + assert.equal(mockMkdir.mock.calls.length, 1); + assert.equal(mockCopyFile.mock.calls.length, 1); + assert.equal(mockStat.mock.calls.length, 1); + assert.equal(mockChmod.mock.calls.length, 1); + assert.equal(mockChmod.mock.calls[0].arguments[1], 0o111); + assert.equal(mockTarCreate.mock.calls.length, 1); + + assert.ok(result.endsWith("darwin-x64-1.0.0.tar.gz")); + assert.ok(path.isAbsolute(result)); + }); + + it("should pack a Windows with .exe", async (t) => { + const mockGenerateNativePackage = mock.fn(); + const mockMkdir = mock.fn(); + const mockCopyFile = mock.fn((_src: string, _dst: string) => + Promise.resolve() + ); + const mockStat = mock.fn(() => Promise.resolve({ mode: 0 })); + const mockChmod = mock.fn(); + const mockTarCreate = mock.fn(); + + t.mock.method(native, "generateNativePackage", mockGenerateNativePackage); + t.mock.method(fs, "mkdir", mockMkdir); + t.mock.method(fs, "stat", mockStat); + t.mock.method(fs, "chmod", mockChmod); + t.mock.method(fs, "copyFile", mockCopyFile); + t.mock.method(tar, "create", mockTarCreate); + + const platform: Platform = { os: "windows", arch: "x64" }; + const version = "1.0.0"; + + const result = await operations.packPlatform({ platform, version }); + + assert.ok( + mockCopyFile.mock.calls[0].arguments[0].endsWith("turbo.exe"), + "source ends with .exe" + ); + assert.ok( + mockCopyFile.mock.calls[0].arguments[1].endsWith("turbo.exe"), + "destination ends with .exe" + ); + assert.equal(mockGenerateNativePackage.mock.calls.length, 1); + assert.equal(mockMkdir.mock.calls.length, 1); + assert.equal(mockCopyFile.mock.calls.length, 1); + assert.equal(mockTarCreate.mock.calls.length, 1); + assert.equal(mockChmod.mock.calls.length, 1); + assert.equal(mockChmod.mock.calls[0].arguments[1], 0o111); + + assert.ok(result.endsWith("windows-x64-1.0.0.tar.gz")); + assert.ok(path.isAbsolute(result)); + }); +}); diff --git a/packages/turbo-releaser/src/operations.ts b/packages/turbo-releaser/src/operations.ts new file mode 100644 index 0000000000000..647c44ea8ba0a --- /dev/null +++ b/packages/turbo-releaser/src/operations.ts @@ -0,0 +1,71 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import { execSync } from "node:child_process"; +import tar from "tar"; +import native from "./native"; +import type { Platform } from "./types"; + +export interface PackOptions { + platform: Platform; + version: string; + // Directory where prebuilt `turbo` binaries are located + // It will also be where `dist` is created and tarballs are put. + // Defaults to cwd + srcDir?: string; +} + +async function packPlatform({ + platform, + version, + srcDir = process.cwd(), +}: PackOptions): Promise { + const { os, arch } = platform; + console.log(`Packing platform: ${os}-${arch}`); + const npmDirName = `turbo-${os}-${arch}`; + const tarballDir = path.join(srcDir, "dist", `${os}-${arch}-${version}`); + const scaffoldDir = path.join(tarballDir, npmDirName); + + console.log("Generating native package..."); + await native.generateNativePackage({ + platform, + version, + outputDir: scaffoldDir, + }); + + console.log("Moving prebuilt binary..."); + const binaryName = os === "windows" ? "turbo.exe" : "turbo"; + const sourcePath = path.join(srcDir, `dist-${os}-${arch}`, binaryName); + const destPath = path.join(scaffoldDir, "bin", binaryName); + await fs.mkdir(path.dirname(destPath), { recursive: true }); + await fs.copyFile(sourcePath, destPath); + // Make sure the binary we copied is executable + const stat = await fs.stat(destPath); + const currMode = stat.mode; + // eslint-disable-next-line no-bitwise -- necessary for enabling the executable bits + await fs.chmod(destPath, currMode | 0o111); + + console.log("Creating tar.gz..."); + const tarName = `${os}-${arch}-${version}.tar.gz`; + const tarPath = path.join(srcDir, "dist", tarName); + await tar.create( + { + gzip: true, + file: tarPath, + cwd: tarballDir, + }, + [npmDirName] + ); + + console.log(`Artifact created: ${tarPath}`); + return path.resolve(tarPath); +} + +function publishArtifacts(artifacts: Array, npmTag: string) { + for (const artifact of artifacts) { + const publishCommand = `npm publish "${artifact}" --tag ${npmTag}`; + console.log(`Executing: ${publishCommand}`); + execSync(publishCommand, { stdio: "inherit" }); + } +} + +export default { packPlatform, publishArtifacts }; diff --git a/packages/turbo-releaser/src/packager.test.ts b/packages/turbo-releaser/src/packager.test.ts new file mode 100644 index 0000000000000..c9568d838e2e3 --- /dev/null +++ b/packages/turbo-releaser/src/packager.test.ts @@ -0,0 +1,55 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert/strict"; +import { packAndPublish } from "./packager"; +import type { Platform } from "./types"; +import operations from "./operations"; + +describe("packager", () => { + describe("packAndPublish", () => { + it("should pack and publish for all platforms when skipPublish is false", async (t) => { + const mockPackPlatform = mock.fn(() => + Promise.resolve("/path/to/artifact.tgz") + ); + const mockPublishArtifacts = mock.fn((_paths: Array) => + Promise.resolve() + ); + t.mock.method(operations, "packPlatform", mockPackPlatform); + t.mock.method(operations, "publishArtifacts", mockPublishArtifacts); + + const platforms: Array = [ + { os: "darwin", arch: "x64" }, + { os: "linux", arch: "arm64" }, + ]; + const version = "1.0.0"; + const npmTag = "latest"; + + await packAndPublish({ platforms, version, skipPublish: false, npmTag }); + + assert.equal(mockPackPlatform.mock.calls.length, 2); + assert.equal(mockPublishArtifacts.mock.calls.length, 1); + assert.deepEqual(mockPublishArtifacts.mock.calls[0].arguments, [ + ["/path/to/artifact.tgz", "/path/to/artifact.tgz"], + "latest", + ]); + }); + + it("should pack but not publish when skipPublish is true", async (t) => { + const mockPackPlatform = mock.fn(() => + Promise.resolve("/path/to/artifact.tgz") + ); + const mockPublishArtifacts = mock.fn(); + + t.mock.method(operations, "packPlatform", mockPackPlatform); + t.mock.method(operations, "publishArtifacts", mockPublishArtifacts); + + const platforms: Array = [{ os: "darwin", arch: "x64" }]; + const version = "1.0.0"; + const npmTag = "latest"; + + await packAndPublish({ platforms, version, skipPublish: true, npmTag }); + + assert.equal(mockPackPlatform.mock.calls.length, 1); + assert.equal(mockPublishArtifacts.mock.calls.length, 0); + }); + }); +}); diff --git a/packages/turbo-releaser/src/packager.ts b/packages/turbo-releaser/src/packager.ts new file mode 100644 index 0000000000000..b7cc8ab17f39f --- /dev/null +++ b/packages/turbo-releaser/src/packager.ts @@ -0,0 +1,35 @@ +import type { Platform } from "./types"; +import operations from "./operations"; + +interface PackAndPublishOptions { + platforms: Array; + version: string; + skipPublish: boolean; + npmTag: string; +} + +export async function packAndPublish({ + platforms, + version, + skipPublish, + npmTag, +}: PackAndPublishOptions) { + console.log("Starting packAndPublish process..."); + const artifacts: Array = []; + + for (const platform of platforms) { + console.log(`Processing platform: ${platform.os}-${platform.arch}`); + // eslint-disable-next-line no-await-in-loop -- We trade of slightly faster releases with more legible logging + const artifact = await operations.packPlatform({ platform, version }); + artifacts.push(artifact); + } + + console.log("All platforms processed. Artifacts:", artifacts); + + if (!skipPublish) { + console.log("Publishing artifacts..."); + operations.publishArtifacts(artifacts, npmTag); + } else { + console.log("Skipping publish step."); + } +} diff --git a/packages/turbo-releaser/src/types.ts b/packages/turbo-releaser/src/types.ts new file mode 100644 index 0000000000000..5b966d31ff010 --- /dev/null +++ b/packages/turbo-releaser/src/types.ts @@ -0,0 +1,9 @@ +export type SupportedOS = "darwin" | "linux" | "windows"; +export type SupportedArch = "x64" | "arm64"; +export type HumanArch = "64" | "arm64"; +export type NpmOs = "darwin" | "linux" | "win32"; + +export interface Platform { + os: SupportedOS; + arch: SupportedArch; +} diff --git a/packages/turbo-releaser/src/version.test.ts b/packages/turbo-releaser/src/version.test.ts new file mode 100644 index 0000000000000..0319c23b2adb1 --- /dev/null +++ b/packages/turbo-releaser/src/version.test.ts @@ -0,0 +1,29 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs/promises"; +import { getVersionInfo } from "./version"; + +describe("getVersionInfo", () => { + it("should read version and npm tag from version.txt", async (t) => { + const mockReadFile = mock.fn((_path, _encoding) => { + return Promise.resolve("1.0.0\nbeta\n"); + }); + t.mock.method(fs, "readFile", mockReadFile); + const result = await getVersionInfo("some-path/version.txt"); + assert.deepStrictEqual(result, { version: "1.0.0", npmTag: "beta" }); + assert.equal( + mockReadFile.mock.calls[0].arguments[0], + "some-path/version.txt" + ); + }); + + it("should throw an error if version.txt is not found", async (t) => { + const mockReadFile = mock.fn((_path, _encoding) => { + return Promise.reject(new Error("File not found")); + }); + t.mock.method(fs, "readFile", mockReadFile); + await assert.rejects(() => getVersionInfo("version.txt"), { + message: "File not found", + }); + }); +}); diff --git a/packages/turbo-releaser/src/version.ts b/packages/turbo-releaser/src/version.ts new file mode 100644 index 0000000000000..a727355f54c2b --- /dev/null +++ b/packages/turbo-releaser/src/version.ts @@ -0,0 +1,11 @@ +import fs from "node:fs/promises"; + +export async function getVersionInfo(versionPath: string): Promise<{ + version: string; + npmTag: string; +}> { + const versionFile = await fs.readFile(versionPath, "utf-8"); + const [version, npmTag] = versionFile.trim().split("\n"); + console.log(`Version: ${version}, NPM Tag: ${npmTag}`); + return { version, npmTag }; +} diff --git a/packages/turbo-releaser/template/LICENSE b/packages/turbo-releaser/template/LICENSE new file mode 100644 index 0000000000000..5c3db8bb6f857 --- /dev/null +++ b/packages/turbo-releaser/template/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2024 Vercel, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/cli/scripts/npm-native-packages/template/README.md b/packages/turbo-releaser/template/README.md similarity index 100% rename from cli/scripts/npm-native-packages/template/README.md rename to packages/turbo-releaser/template/README.md diff --git a/cli/scripts/npm-native-packages/template/bin/turbo b/packages/turbo-releaser/template/bin/turbo old mode 100644 new mode 100755 similarity index 100% rename from cli/scripts/npm-native-packages/template/bin/turbo rename to packages/turbo-releaser/template/bin/turbo diff --git a/packages/turbo-releaser/tsconfig.json b/packages/turbo-releaser/tsconfig.json new file mode 100644 index 0000000000000..e6f9fc6aa70b6 --- /dev/null +++ b/packages/turbo-releaser/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@turbo/tsconfig/library.json", + "compilerOptions": { + "rootDir": ".", + "module": "ESNext", + "lib": ["ESNext"] + } +} diff --git a/packages/turbo-releaser/tsup.config.ts b/packages/turbo-releaser/tsup.config.ts new file mode 100644 index 0000000000000..b78e5518aef2a --- /dev/null +++ b/packages/turbo-releaser/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig, Options } from "tsup"; + +export default defineConfig((options: Options) => ({ + entry: ["src/index.ts"], + format: ["cjs"], + clean: true, + minify: true, + ...options, +})); diff --git a/packages/turbo-releaser/turbo.json b/packages/turbo-releaser/turbo.json new file mode 100644 index 0000000000000..07dd354a7722d --- /dev/null +++ b/packages/turbo-releaser/turbo.json @@ -0,0 +1,9 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist"] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c6f595b5617c..92a9dc7791a66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,7 +46,11 @@ importers: specifier: ^0.36.0 version: 0.36.0 - cli: {} + cli: + dependencies: + '@turbo/releaser': + specifier: workspace:* + version: link:../packages/turbo-releaser docs: dependencies: @@ -560,6 +564,37 @@ importers: specifier: 5.5.4 version: 5.5.4 + packages/turbo-releaser: + dependencies: + commander: + specifier: ^11.0.0 + version: 11.0.0 + tar: + specifier: 6.1.13 + version: 6.1.13 + devDependencies: + '@turbo/eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@turbo/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@types/node': + specifier: ^20 + version: 20.11.30 + '@types/tar': + specifier: ^6.1.4 + version: 6.1.4 + tsup: + specifier: ^6.7.0 + version: 6.7.0(ts-node@10.9.2)(typescript@5.5.4) + tsx: + specifier: 4.19.1 + version: 4.19.1 + typescript: + specifier: 5.5.4 + version: 5.5.4 + packages/turbo-repository: devDependencies: '@napi-rs/cli': @@ -4188,7 +4223,6 @@ packages: /chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} - dev: true /ci-info@3.8.0: resolution: {integrity: sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==} @@ -6360,7 +6394,6 @@ packages: engines: {node: '>= 8'} dependencies: minipass: 3.3.6 - dev: true /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -8893,14 +8926,12 @@ packages: engines: {node: '>=8'} dependencies: yallist: 4.0.0 - dev: true /minipass@4.0.0: resolution: {integrity: sha512-g2Uuh2jEKoht+zvO6vJqXmYpflPqzRBT+Th2h01DKh5z7wbY/AZ2gCQ78cP70YoHPyFdY30YBV5WxgLOEwOykw==} engines: {node: '>=8'} dependencies: yallist: 4.0.0 - dev: true /minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} @@ -8913,7 +8944,6 @@ packages: dependencies: minipass: 3.3.6 yallist: 4.0.0 - dev: true /mixin-deep@1.3.2: resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==} @@ -8932,7 +8962,7 @@ packages: /mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} - dev: true + hasBin: true /ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -10720,7 +10750,6 @@ packages: minizlib: 2.1.2 mkdirp: 1.0.4 yallist: 4.0.0 - dev: true /test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} @@ -11140,6 +11169,7 @@ packages: /tsx@4.19.1: resolution: {integrity: sha512-0flMz1lh74BR4wOvBjuh9olbnwqCPc35OOlfyzHba0Dc+QNUeWX/Gq2YTbnwcWPO3BMd8fkzRVrHcsR+a7z7rA==} engines: {node: '>=18.0.0'} + hasBin: true dependencies: esbuild: 0.23.1 get-tsconfig: 4.7.6 @@ -11288,6 +11318,7 @@ packages: /typescript@5.5.4: resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} engines: {node: '>=14.17'} + hasBin: true /uglify-js@3.17.4: resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==}