diff --git a/.github/scripts/has-unpublished-packages.mjs b/.github/scripts/has-unpublished-packages.mjs new file mode 100644 index 0000000..ae25838 --- /dev/null +++ b/.github/scripts/has-unpublished-packages.mjs @@ -0,0 +1,85 @@ +import { appendFileSync, existsSync } from 'node:fs'; +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; + +const ignoredDirectories = new Set(['.git', 'dist', 'node_modules']); +const workspaceRoot = process.cwd(); +const hasWorkspaceManifest = existsSync(path.join(workspaceRoot, 'pnpm-workspace.yaml')); + +async function findPackageManifests(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const manifests = []; + + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) { + manifests.push(...(await findPackageManifests(fullPath))); + } + } else if (entry.isFile() && entry.name === 'package.json') { + if (hasWorkspaceManifest && fullPath === path.join(workspaceRoot, 'package.json')) { + continue; + } + + const pkg = JSON.parse(await readFile(fullPath, 'utf8')); + + if (!pkg.private && pkg.name && pkg.version) { + manifests.push({ name: pkg.name, version: pkg.version }); + } + } + } + + return manifests; +} + +async function hasPublishedVersion(pkg) { + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(pkg.name)}`, { + headers: { accept: 'application/vnd.npm.install-v1+json' }, + }); + + if (response.status === 404) { + return false; + } + + if (!response.ok) { + throw new Error(`Failed to query ${pkg.name}: ${response.status} ${response.statusText}`); + } + + const metadata = await response.json(); + return Object.prototype.hasOwnProperty.call(metadata.versions ?? {}, pkg.version); +} + +async function main() { + const packages = (await findPackageManifests(process.cwd())).sort((a, b) => + a.name.localeCompare(b.name) + ); + let hasUnpublished = false; + + for (const pkg of packages) { + const isPublished = await hasPublishedVersion(pkg); + + if (isPublished) { + console.log(`${pkg.name}@${pkg.version} is already published`); + } else { + console.log(`${pkg.name}@${pkg.version} is not published yet`); + hasUnpublished = true; + } + } + + const output = + [`has_unpublished=${String(hasUnpublished)}`, `should_publish=${String(hasUnpublished)}`].join( + '\n' + ) + '\n'; + + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, output); + } else { + process.stdout.write(output); + } +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/.github/scripts/stage-packages.mjs b/.github/scripts/stage-packages.mjs new file mode 100644 index 0000000..34cf4b0 --- /dev/null +++ b/.github/scripts/stage-packages.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; + +const root = process.cwd(); +const config = JSON.parse(readFileSync(join(root, ".changeset/config.json"), "utf8")); +const ignored = new Set(config.ignore || []); +const access = config.access || "public"; + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function packageJsonPathsFromWorkspace() { + const paths = []; + + function visit(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if ( + entry.name === ".git" || + entry.name === "node_modules" || + entry.name === ".pnpm" || + entry.name === "dist" || + entry.name === "coverage" + ) { + continue; + } + + const path = join(dir, entry.name); + if (entry.isDirectory()) { + visit(path); + } else if (entry.isFile() && entry.name === "package.json") { + paths.push(path); + } + } + } + + visit(root); + return paths; +} + +function packageJsonPaths() { + return [...new Set(packageJsonPathsFromWorkspace())].filter(existsSync); +} + +function versionExists(name, version) { + const result = spawnSync("npm", ["view", `${name}@${version}`, "version", "--json"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.status === 0) return true; + const output = `${result.stdout} +${result.stderr}`; + if (output.includes("E404") || output.includes("No match found")) return false; + + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + throw new Error(`Could not check npm version for ${name}@${version}`); +} + +function distTag(version) { + const prerelease = version.match(/^[^-]+-([0-9A-Za-z-]+)/); + return prerelease ? prerelease[1] : "latest"; +} + +function runGit(args) { + const result = spawnSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + if (result.status !== 0) process.exit(result.status || 1); +} + +function hasLocalGitTag(tagName) { + const result = spawnSync( + "git", + ["rev-parse", "--verify", "--quiet", `refs/tags/${tagName}`], + { + cwd: root, + stdio: "ignore", + } + ); + return result.status === 0; +} + +function createGitTag(tagName) { + if (hasLocalGitTag(tagName)) { + console.log(`Git tag ${tagName} already exists locally.`); + } else { + runGit(["tag", tagName, "-m", tagName]); + } + + // changesets/action parses this line, then pushes the tag and creates the GitHub release. + console.log(`New tag: ${tagName}`); +} + +const staged = []; +for (const packageJsonPath of packageJsonPaths()) { + const pkg = readJson(packageJsonPath); + if (!pkg.name || !pkg.version || pkg.private || ignored.has(pkg.name)) continue; + if (versionExists(pkg.name, pkg.version)) { + console.log(`Skipping ${pkg.name}@${pkg.version}; already published.`); + continue; + } + + const packageDir = dirname(packageJsonPath); + const tag = distTag(pkg.version); + const args = [ + "stage", + "publish", + packageDir, + "--provenance", + "--access", + pkg.publishConfig?.access || access, + "--tag", + tag, + "--json", + ]; + + console.log(`Staging ${pkg.name}@${pkg.version} with dist-tag ${tag}...`); + const result = spawnSync("pnpm", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + if (result.status !== 0) process.exit(result.status || 1); + + const stageId = result.stdout.match(/"stageId"\s*:\s*"([^"]+)"/)?.[1]; + const tagName = `${pkg.name}@${pkg.version}`; + createGitTag(tagName); + + staged.push({ + name: pkg.name, + version: pkg.version, + path: relative(root, packageDir) || ".", + stageId, + }); +} + +if (staged.length === 0) { + console.log("No unpublished packages to stage."); +} else { + console.log("Staged packages:"); + for (const pkg of staged) { + console.log(`- ${pkg.name}@${pkg.version}${pkg.stageId ? ` (${pkg.stageId})` : ""}`); + } + console.log("Approve staged packages with `npm stage approve ` after review."); +} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index f80a9d7..a7f7362 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -8,13 +8,72 @@ jobs: name: Release runs-on: ubuntu-24.04 timeout-minutes: 20 + outputs: + has_changesets: ${{ steps.changesets.outputs.hasChangesets }} + has_unpublished_packages: ${{ steps.unpublished.outputs.has_unpublished }} + permissions: + contents: write + issues: write + pull-requests: write + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 11.3.0 + run_install: false + + - name: Get pnpm store directory + id: pnpm-store + run: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Use pnpm store + uses: actions/cache@v4 + id: pnpm-cache + with: + path: ${{ steps.pnpm-store.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: Check for unpublished package versions + id: unpublished + run: node .github/scripts/has-unpublished-packages.mjs + + - name: Install Dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Update npm + run: npm install -g npm@11.15.0 + + - name: Create Release Pull Request + id: changesets + uses: changesets/action@v1.5.3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish: + name: Publish + needs: release + if: needs.release.outputs.has_changesets == 'false' && needs.release.outputs.has_unpublished_packages == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + environment: + name: npm + url: https://www.npmjs.com/package/@0no-co/graphqlsp permissions: contents: write id-token: write issues: write - repository-projects: write - deployments: write - packages: write pull-requests: write steps: - name: Checkout Repo @@ -25,12 +84,12 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Setup pnpm uses: pnpm/action-setup@v3 with: - version: 8.6.1 + version: 11.3.0 run_install: false - name: Get pnpm store directory @@ -50,13 +109,14 @@ jobs: run: pnpm install --frozen-lockfile --prefer-offline - name: Update npm - run: npm install -g npm@11.6.2 + run: npm install -g npm@11.15.0 - - name: PR or Publish + - name: Publish packages id: changesets uses: changesets/action@v1.5.3 with: - publish: pnpm changeset publish + publish: node .github/scripts/stage-packages.mjs + createGithubReleases: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -70,8 +130,52 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + publish-prerelease: + name: Publish Prerelease + needs: release + if: needs.release.outputs.has_changesets == 'false' && needs.release.outputs.has_unpublished_packages != 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write + id-token: write + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 11.3.0 + run_install: false + + - name: Get pnpm store directory + id: pnpm-store + run: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Use pnpm store + uses: actions/cache@v4 + id: pnpm-cache + with: + path: ${{ steps.pnpm-store.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: Install Dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Update npm + run: npm install -g npm@11.15.0 + - name: Publish Prerelease - if: steps.changesets.outputs.published != 'true' continue-on-error: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}