From 41f8287ba7b148f2b8e24dda5d7603a56e9f23fc Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Sat, 13 Jun 2026 15:16:41 +0200 Subject: [PATCH] ci: gate npm publish with an environment (#67) --- .github/scripts/has-unpublished-packages.mjs | 79 +++++++++++++ .github/workflows/release.yml | 112 +++++++++++++++++-- 2 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 .github/scripts/has-unpublished-packages.mjs diff --git a/.github/scripts/has-unpublished-packages.mjs b/.github/scripts/has-unpublished-packages.mjs new file mode 100644 index 0000000..ec98932 --- /dev/null +++ b/.github/scripts/has-unpublished-packages.mjs @@ -0,0 +1,79 @@ +import { appendFileSync } from 'node:fs'; +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; + +const ignoredDirectories = new Set(['.git', 'dist', 'node_modules']); + +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') { + 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/workflows/release.yml b/.github/workflows/release.yml index 5cb0ece..4c3002c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,13 +9,12 @@ jobs: name: Release runs-on: ubuntu-22.04 timeout-minutes: 20 + outputs: + has_changesets: ${{ steps.changesets.outputs.hasChangesets }} + has_unpublished_packages: ${{ steps.unpublished.outputs.has_unpublished }} permissions: contents: write - id-token: write issues: write - repository-projects: write - deployments: write - packages: write pull-requests: write steps: - name: Checkout Repo @@ -36,7 +35,7 @@ jobs: - name: Get pnpm store directory id: pnpm-store - run: echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" + run: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT - name: Use pnpm store uses: actions/cache@v4 @@ -47,14 +46,72 @@ jobs: 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: PR or Publish + - name: Create Release Pull Request id: changesets uses: changesets/action@v1.5.3 with: version: pnpm changeset:version + 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-22.04 + timeout-minutes: 20 + environment: + name: npm + url: https://www.npmjs.com/package/@0no-co/graphql.web + permissions: + contents: write + id-token: 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: 20 + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 9 + 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: Publish packages + id: changesets + uses: changesets/action@v1.5.3 + with: publish: pnpm changeset:publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -69,8 +126,49 @@ 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-22.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: 20 + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 9 + 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: Publish Prerelease - if: steps.changesets.outputs.published != 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | -- knotmirror 0.0.1