diff --git a/.github/tools/go.mod b/.github/tools/go.mod new file mode 100644 index 00000000..acb908f8 --- /dev/null +++ b/.github/tools/go.mod @@ -0,0 +1,19 @@ +module github.com/zx06/xsql/.github/tools + +go 1.25.0 + +require github.com/rhysd/actionlint v1.7.7 + +require ( + github.com/bmatcuk/doublestar/v4 v4.8.0 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-shellwords v1.0.12 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.29.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/.github/tools/go.sum b/.github/tools/go.sum new file mode 100644 index 00000000..c1d42a63 --- /dev/null +++ b/.github/tools/go.sum @@ -0,0 +1,30 @@ +github.com/bmatcuk/doublestar/v4 v4.8.0 h1:DSXtrypQddoug1459viM9X9D3dp1Z7993fw36I2kNcQ= +github.com/bmatcuk/doublestar/v4 v4.8.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= +github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/rhysd/actionlint v1.7.7 h1:0KgkoNTrYY7vmOCs9BW2AHxLvvpoY9nEUzgBHiPUr0k= +github.com/rhysd/actionlint v1.7.7/go.mod h1:AE6I6vJEkNaIfWqC2GNE5spIJNhxf8NCtLEKU4NnUXg= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/.github/tools/tools.go b/.github/tools/tools.go new file mode 100644 index 00000000..4e14c62f --- /dev/null +++ b/.github/tools/tools.go @@ -0,0 +1,3 @@ +package tools + +import _ "github.com/rhysd/actionlint/cmd/actionlint" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d79d73f..9887dcae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,23 +55,40 @@ jobs: shell: bash run: gotestsum --junitfile test-results.xml -- -v -race -coverprofile=coverage.txt -covermode=atomic ./... + - name: Detect Codecov token + id: codecov_token + shell: bash + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + run: | + if [ -n "$CODECOV_TOKEN" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "Skipping Codecov upload: CODECOV_TOKEN is not available (e.g. fork or Dependabot PR)." + fi + - name: Upload test results to Codecov + if: steps.codecov_token.outputs.present == 'true' uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: test-results.xml flags: unittests report_type: test_results - fail_ci_if_error: false + fail_ci_if_error: true + skip_validation: true - name: Upload coverage to Codecov + if: steps.codecov_token.outputs.present == 'true' uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.txt flags: unittests name: codecov-${{ matrix.os }} - fail_ci_if_error: false + fail_ci_if_error: true + skip_validation: true verbose: true integration: @@ -150,23 +167,39 @@ jobs: XSQL_TEST_PG_DSN: "postgres://postgres:postgres@127.0.0.1:5432/testdb?sslmode=disable" run: gotestsum --junitfile e2e-test-results.xml -- -v -tags=e2e -coverprofile=coverage-e2e.txt -covermode=atomic -coverpkg=github.com/zx06/xsql/internal/... ./tests/e2e/... + - name: Detect Codecov token + id: codecov_token + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + run: | + if [ -n "$CODECOV_TOKEN" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "Skipping Codecov upload: CODECOV_TOKEN is not available (e.g. fork or Dependabot PR)." + fi + - name: Upload integration test results to Codecov + if: steps.codecov_token.outputs.present == 'true' uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: integration-test-results.xml,e2e-test-results.xml flags: integration,e2e report_type: test_results - fail_ci_if_error: false + fail_ci_if_error: true + skip_validation: true - name: Upload integration coverage to Codecov + if: steps.codecov_token.outputs.present == 'true' uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage-integration.txt,coverage-e2e.txt flags: integration,e2e name: codecov-integration - fail_ci_if_error: false + fail_ci_if_error: true + skip_validation: true verbose: true disable_search: true diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml new file mode 100644 index 00000000..4d6c6377 --- /dev/null +++ b/.github/workflows/release-dry-run.yml @@ -0,0 +1,91 @@ +name: Release dry run + +on: + pull_request: + paths: + - ".github/workflows/release*.yml" + - ".goreleaser.yaml" + - "npm/**" + - "scripts/npm-publish.js" + - "scripts/release-smoke.js" + - "scripts/release-artifacts.js" + - ".github/tools/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + actionlint: + name: actionlint + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + with: + go-version: "1.25" + cache: false + + - name: Run actionlint + env: + WORKFLOW_DIR: ${{ github.workspace }}/.github/workflows + run: | + shopt -s nullglob + mapfile -t workflow_files < <(printf '%s\n' "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml) + cd .github/tools + go run github.com/rhysd/actionlint/cmd/actionlint -color "${workflow_files[@]}" + + build: + name: GoReleaser snapshot build + runs-on: ubuntu-latest + needs: actionlint + permissions: + contents: read + actions: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + with: + go-version: "1.25" + cache: true + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: webui/package-lock.json + + - name: GoReleaser snapshot build + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + with: + distribution: goreleaser + version: "~> v2" + args: build --snapshot --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload dist artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: goreleaser-dist + path: dist/ + retention-days: 3 + + smoke: + name: Release smoke tests + needs: build + permissions: + contents: read + actions: read + uses: ./.github/workflows/release-smoke-reusable.yml + with: + dist-artifact-name: goreleaser-dist diff --git a/.github/workflows/release-smoke-reusable.yml b/.github/workflows/release-smoke-reusable.yml new file mode 100644 index 00000000..4b971f78 --- /dev/null +++ b/.github/workflows/release-smoke-reusable.yml @@ -0,0 +1,89 @@ +name: Release smoke (reusable) + +on: + workflow_call: + inputs: + dist-artifact-name: + description: Artifact name containing the GoReleaser dist/ directory + required: true + type: string + +permissions: + contents: read + actions: read + +jobs: + smoke-binary: + name: Binary smoke (${{ matrix.label }}) + runs-on: ${{ matrix.runner }} + permissions: + contents: read + actions: read + strategy: + fail-fast: false + matrix: + include: + - label: linux-amd64 + runner: ubuntu-latest + goos: linux + goarch: amd64 + - label: linux-arm64 + runner: ubuntu-24.04-arm + goos: linux + goarch: arm64 + - label: darwin-arm64 + runner: macos-latest + goos: darwin + goarch: arm64 + - label: windows-amd64 + runner: windows-latest + goos: windows + goarch: amd64 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + + - name: Download GoReleaser dist + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ inputs.dist-artifact-name }} + path: dist + + - name: Smoke test binary + shell: bash + env: + SMOKE_GOOS: ${{ matrix.goos }} + SMOKE_GOARCH: ${{ matrix.goarch }} + run: node scripts/release-smoke.js binary dist "$SMOKE_GOOS" "$SMOKE_GOARCH" + + smoke-npm: + name: npm smoke (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + permissions: + contents: read + actions: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + + - name: Download GoReleaser dist + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ inputs.dist-artifact-name }} + path: dist + + - name: Smoke test npm package + shell: bash + run: node scripts/release-smoke.js npm dist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe1dfb3d..310cbdf4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,9 +5,64 @@ on: tags: - "v*" +permissions: + contents: read + jobs: + build: + name: GoReleaser snapshot build + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + with: + go-version: "1.25" + cache: true + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: webui/package-lock.json + + - name: GoReleaser snapshot build + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + with: + distribution: goreleaser + version: "~> v2" + args: build --snapshot --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload dist artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: goreleaser-dist + path: dist/ + retention-days: 7 + + smoke: + name: Release smoke tests + needs: build + permissions: + contents: read + actions: read + uses: ./.github/workflows/release-smoke-reusable.yml + with: + dist-artifact-name: goreleaser-dist + release: - name: Release + name: Publish release + needs: smoke runs-on: ubuntu-latest permissions: contents: write @@ -24,7 +79,7 @@ jobs: cache: true - name: Set up Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "24" cache: "npm" @@ -52,7 +107,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Node.js - uses: actions/setup-node@v6.4.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "24" registry-url: "https://registry.npmjs.org" diff --git a/docs/dev.md b/docs/dev.md index da441ec0..56e0012e 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -140,6 +140,27 @@ docs/ # 文档 - 单元测试(所有平台) - 集成测试(MySQL 8.0 + PostgreSQL 16) - 代码检查(golangci-lint) -- 代码覆盖率上报 +- 代码覆盖率上报(仓库内 PR / `main` 推送在配置了 `CODECOV_TOKEN` 时上传;fork / Dependabot PR 无 token 时会跳过上传,且不会因 Codecov 失败而阻塞) 详见 `.github/workflows/ci.yml`。 + +### 发布与发布前检查 + +正式发布由 tag 推送触发(`.github/workflows/release.yml`),顺序为: + +1. **GoReleaser snapshot 构建**(`goreleaser build --snapshot`),产出各平台二进制到 `dist/` 并上传 artifact。 +2. **冒烟测试**(`.github/workflows/release-smoke-reusable.yml`): + - 二进制:在 linux/amd64、linux/arm64、darwin/arm64、windows/amd64 上运行 `xsql version`、`xsql spec`、`xsql profile list`(脚本:`scripts/release-smoke.js binary`)。 + - npm:在 Linux / macOS / Windows 上从 `dist/` 填充平台包、`npm pack` 后本地安装 tarball,执行 `npx xsql version` 与 `npx xsql spec`(`scripts/release-smoke.js npm`)。 +3. **GoReleaser release**(`goreleaser release --clean`)——仅在上一步全部通过后创建 GitHub Release 并推送 Homebrew/Scoop 等。 +4. **npm 发布**(`scripts/npm-publish.js`)——从 GitHub Release 下载二进制后发布 `xsql-cli` 与各 `@zx06/xsql-cli-*` 平台包。 + +修改发布相关文件时,PR 会触发 **Release dry run**(`.github/workflows/release-dry-run.yml`):`actionlint` 校验 workflow、snapshot 构建与上述冒烟测试(不发布)。也可在 Actions 中手动 **workflow_dispatch** 该 workflow。 + +本地可复现 snapshot + 冒烟: + +```bash +goreleaser build --snapshot --clean +node scripts/release-smoke.js binary dist linux amd64 +node scripts/release-smoke.js npm dist +``` diff --git a/scripts/npm-publish.js b/scripts/npm-publish.js index 10d906f7..5b08b0ef 100644 --- a/scripts/npm-publish.js +++ b/scripts/npm-publish.js @@ -7,19 +7,12 @@ const https = require("https"); const http = require("http"); const { createWriteStream, mkdirSync } = require("fs"); +const { PLATFORM_MAP } = require("./release-artifacts"); + const REPO = "zx06/xsql"; const NPM_DIR = path.join(__dirname, "..", "npm"); const NPM_SCOPE = "@zx06"; -const PLATFORM_MAP = [ - { goos: "linux", goarch: "amd64", npm: "linux-x64", ext: "", archive: "tar.gz" }, - { goos: "linux", goarch: "arm64", npm: "linux-arm64", ext: "", archive: "tar.gz" }, - { goos: "darwin", goarch: "amd64", npm: "darwin-x64", ext: "", archive: "tar.gz" }, - { goos: "darwin", goarch: "arm64", npm: "darwin-arm64", ext: "", archive: "tar.gz" }, - { goos: "windows", goarch: "amd64", npm: "win32-x64", ext: ".exe", archive: "zip" }, - { goos: "windows", goarch: "arm64", npm: "win32-arm64", ext: ".exe", archive: "zip" }, -]; - function run(cmd, opts = {}) { console.log(`$ ${cmd}`); return execSync(cmd, { stdio: "inherit", ...opts }); @@ -178,5 +171,5 @@ module.exports = { buildPublishCommand, getPrereleaseTag, }; - - + + diff --git a/scripts/release-artifacts.js b/scripts/release-artifacts.js new file mode 100644 index 00000000..35201d2f --- /dev/null +++ b/scripts/release-artifacts.js @@ -0,0 +1,10 @@ +const PLATFORM_MAP = [ + { goos: "linux", goarch: "amd64", npm: "linux-x64", ext: "", archive: "tar.gz" }, + { goos: "linux", goarch: "arm64", npm: "linux-arm64", ext: "", archive: "tar.gz" }, + { goos: "darwin", goarch: "amd64", npm: "darwin-x64", ext: "", archive: "tar.gz" }, + { goos: "darwin", goarch: "arm64", npm: "darwin-arm64", ext: "", archive: "tar.gz" }, + { goos: "windows", goarch: "amd64", npm: "win32-x64", ext: ".exe", archive: "zip" }, + { goos: "windows", goarch: "arm64", npm: "win32-arm64", ext: ".exe", archive: "zip" }, +]; + +module.exports = { PLATFORM_MAP }; diff --git a/scripts/release-smoke.js b/scripts/release-smoke.js new file mode 100644 index 00000000..cf1b8360 --- /dev/null +++ b/scripts/release-smoke.js @@ -0,0 +1,219 @@ +#!/usr/bin/env node + +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { PLATFORM_MAP } = require("./release-artifacts"); + +const ALLOWED_PLATFORMS = new Set( + PLATFORM_MAP.map((p) => `${p.goos}/${p.goarch}`), +); + +function readJSON(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function assertAllowedPlatform(goos, goarch) { + const key = `${goos}/${goarch}`; + if (!ALLOWED_PLATFORMS.has(key)) { + throw new Error(`unsupported smoke platform: ${key}`); + } +} + +function findBinaryArtifact(distDir, goos, goarch) { + assertAllowedPlatform(goos, goarch); + const artifactsPath = path.join(distDir, "artifacts.json"); + if (!fs.existsSync(artifactsPath)) { + throw new Error(`missing ${artifactsPath}; run GoReleaser build first`); + } + const artifacts = readJSON(artifactsPath); + const match = artifacts.find( + (item) => + item.type === "Binary" && + item.goos === goos && + item.goarch === goarch && + item.path, + ); + if (!match) { + throw new Error(`no binary artifact for ${goos}/${goarch} in ${artifactsPath}`); + } + const relative = match.path.replace(/^dist[\\/]/, ""); + const binaryPath = path.isAbsolute(match.path) + ? match.path + : path.join(distDir, relative); + if (!fs.existsSync(binaryPath)) { + throw new Error(`binary not found at ${binaryPath}`); + } + return binaryPath; +} + +/** Owner/group/other read+execute only (no write for group/other). */ +function chmodExecutable(filePath) { + if (process.platform === "win32") { + return; + } + const stats = fs.statSync(filePath); + const withoutWrite = stats.mode & ~0o022; + const executable = withoutWrite | 0o111; + fs.chmodSync(filePath, executable & 0o755); +} + +function resolveNodeCliTool(toolName) { + const ext = process.platform === "win32" ? ".cmd" : ""; + return path.join(path.dirname(process.execPath), `${toolName}${ext}`); +} + +function runBinarySmoke(binaryPath) { + chmodExecutable(binaryPath); + const commands = [ + ["version"], + ["spec", "--format", "json"], + ["profile", "list", "--format", "json"], + ]; + for (const args of commands) { + console.log(`> ${binaryPath} ${args.join(" ")}`); + execFileSync(binaryPath, args, { stdio: "inherit", env: process.env }); + } +} + +function copyBinaryToNpm(distDir, version) { + const repoRoot = path.join(__dirname, ".."); + const npmDir = path.join(repoRoot, "npm"); + for (const platform of PLATFORM_MAP) { + const src = findBinaryArtifact(distDir, platform.goos, platform.goarch); + const binDir = path.join(npmDir, platform.npm, "bin"); + const dest = path.join(binDir, `xsql${platform.ext}`); + fs.mkdirSync(binDir, { recursive: true }); + fs.copyFileSync(src, dest); + if (platform.ext === "") { + chmodExecutable(dest); + } + } + updateNpmVersions(npmDir, version); +} + +function updateNpmVersions(npmDir, version) { + const mainPkg = path.join(npmDir, "xsql", "package.json"); + const main = readJSON(mainPkg); + main.version = version; + if (main.optionalDependencies) { + for (const dep of Object.keys(main.optionalDependencies)) { + main.optionalDependencies[dep] = version; + } + } + fs.writeFileSync(mainPkg, JSON.stringify(main, null, 2) + "\n"); + for (const platform of PLATFORM_MAP) { + const pkgPath = path.join(npmDir, platform.npm, "package.json"); + const pkg = readJSON(pkgPath); + pkg.version = version; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + } +} + +function resolveVersion(distDir) { + const metadataPath = path.join(distDir, "metadata.json"); + if (fs.existsSync(metadataPath)) { + return readJSON(metadataPath).version; + } + return "0.0.0-smoke"; +} + +function runNodeCliTool(toolName, args, cwd, options = {}) { + const toolPath = resolveNodeCliTool(toolName); + const execOptions = { cwd, ...options }; + if (process.platform === "win32") { + execFileSync(toolPath, args, { ...execOptions, shell: true }); + return; + } + execFileSync(toolPath, args, execOptions); +} + +function runNpm(args, cwd) { + runNodeCliTool("npm", args, cwd, { stdio: "inherit" }); +} + +function runNpx(args, cwd) { + runNodeCliTool("npx", args, cwd, { stdio: "inherit" }); +} + +function packTarball(cwd) { + const toolPath = resolveNodeCliTool("npm"); + const execOptions = { cwd, encoding: "utf8" }; + const output = + process.platform === "win32" + ? execFileSync(toolPath, ["pack", "--json"], { ...execOptions, shell: true }) + : execFileSync(toolPath, ["pack", "--json"], execOptions); + return parsePackOutput(output, cwd); +} + +function parsePackOutput(output, cwd) { + const parsed = JSON.parse(output); + const entry = Array.isArray(parsed) ? parsed[0] : parsed; + return path.resolve(cwd, entry.filename); +} + +function smokeNpmFromDist(distDir) { + const repoRoot = path.join(__dirname, ".."); + const npmDir = path.join(repoRoot, "npm"); + const version = resolveVersion(distDir); + copyBinaryToNpm(distDir, version); + + const key = `${os.platform()}-${os.arch()}`; + const platform = PLATFORM_MAP.find((p) => p.npm === key); + if (!platform) { + throw new Error(`unsupported npm smoke platform: ${key}`); + } + + const platformDir = path.join(npmDir, platform.npm); + const mainDir = path.join(npmDir, "xsql"); + const platformTgz = packTarball(platformDir); + const mainTgz = packTarball(mainDir); + + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "xsql-npm-smoke-")); + console.log(`npm smoke workdir: ${workDir}`); + runNpm(["init", "-y"], workDir); + runNpm(["install", platformTgz, mainTgz], workDir); + runNpx(["xsql", "version"], workDir); + runNpx(["xsql", "spec", "--format", "json"], workDir); +} + +function main() { + const [mode, ...rest] = process.argv.slice(2); + const distDir = path.resolve(rest[0] || "dist"); + + if (mode === "binary") { + const [goos, goarch] = rest.slice(1); + if (!goos || !goarch) { + console.error("Usage: node scripts/release-smoke.js binary "); + process.exit(1); + } + runBinarySmoke(findBinaryArtifact(distDir, goos, goarch)); + return; + } + + if (mode === "npm") { + smokeNpmFromDist(distDir); + return; + } + + console.error("Usage:"); + console.error(" node scripts/release-smoke.js binary [distDir] "); + console.error(" node scripts/release-smoke.js npm [distDir]"); + process.exit(1); +} + +if (require.main === module) { + try { + main(); + } catch (err) { + console.error(err.message || err); + process.exit(1); + } +} + +module.exports = { + findBinaryArtifact, + resolveVersion, +}; diff --git a/scripts/release-smoke.test.js b/scripts/release-smoke.test.js new file mode 100644 index 00000000..8148d1d3 --- /dev/null +++ b/scripts/release-smoke.test.js @@ -0,0 +1,38 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { findBinaryArtifact, resolveVersion } = require("./release-smoke"); + +test("findBinaryArtifact resolves binary path from artifacts.json", () => { + const distDir = fs.mkdtempSync(path.join(os.tmpdir(), "xsql-dist-")); + const binaryRel = path.join("dist", "xsql_linux_amd64_v1", "xsql"); + const binaryAbs = path.join(distDir, "xsql_linux_amd64_v1", "xsql"); + fs.mkdirSync(path.dirname(binaryAbs), { recursive: true }); + fs.writeFileSync(binaryAbs, ""); + fs.writeFileSync( + path.join(distDir, "artifacts.json"), + JSON.stringify([ + { + type: "Binary", + goos: "linux", + goarch: "amd64", + path: binaryRel, + }, + ]), + ); + + assert.equal(findBinaryArtifact(distDir, "linux", "amd64"), binaryAbs); + assert.equal(resolveVersion(distDir), "0.0.0-smoke"); +}); + +test("findBinaryArtifact rejects unknown platform", () => { + const distDir = fs.mkdtempSync(path.join(os.tmpdir(), "xsql-dist-")); + fs.writeFileSync(path.join(distDir, "artifacts.json"), "[]"); + assert.throws( + () => findBinaryArtifact(distDir, "plan9", "amd64"), + /unsupported smoke platform/, + ); +});