diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml deleted file mode 100644 index 99de6b6..0000000 --- a/.github/workflows/pre-release.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Pre-release - -permissions: - contents: read - -on: - workflow_dispatch: - push: - branches: - - release-please-* - -jobs: - pre-release: - name: 'Verify artifacts before release' - runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 2 - submodules: true - - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - cache: npm - node-version-file: '.nvmrc' - registry-url: 'https://registry.npmjs.org' - - # Ensure npm 11.5.1 or later is installed - - name: Update npm - run: npm install -g npm@latest - - - name: Install dependencies - run: npm ci - - - name: Build and bundle - run: npm run bundle - env: - NODE_ENV: 'production' - - - name: Verify npm package - run: npm run verify-npm-package diff --git a/.github/workflows/publish-to-npm-on-tag.yml b/.github/workflows/publish-to-npm-on-tag.yml index 222fad9..c0f2c82 100644 --- a/.github/workflows/publish-to-npm-on-tag.yml +++ b/.github/workflows/publish-to-npm-on-tag.yml @@ -37,6 +37,11 @@ jobs: env: NODE_ENV: 'production' + - name: Verify tarball and release tag + run: npm run verify-npm-package + env: + RELEASE_TAG: ${{ github.ref_name }} + - name: Publish opera-devtools-mcp run: | npm publish --provenance --access public diff --git a/docs/UPSTREAM.md b/docs/UPSTREAM.md index 753bb15..7477fc1 100644 --- a/docs/UPSTREAM.md +++ b/docs/UPSTREAM.md @@ -96,6 +96,7 @@ a result: | `scripts/generate-cli.ts` | Opera attribution in the file header and in the generated-file header template | yes | | `scripts/post-build.ts` | Adds `makeBinScriptsExecutable()` so generated JS bin scripts stay executable (tsc does not preserve file permissions) | yes | | `scripts/test.js` | Opera env var keys | yes | +| `scripts/verify-npm-package.js` | Reads `npm pack --dry-run` instead of `npm publish --dry-run`, derives entry points from `package.json` `bin`, and checks the release tag names the packed version | yes | | `tsconfig.json` | `ESNext.Iterator`/`ESNext.Collection` → `ES2025.*` | **temporary** — TS lib workaround | ### Upstream tests we modify @@ -122,12 +123,13 @@ resolve by hand, keeping the Opera side. Git leaves upstream's copy in the tree reflexive `git add -A` brings the file back — `npm run verify-upstream-seam` fails if any path in this table reappears. -| Path | What we did | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `tests/e2e/chrome-devtools-commands.test.ts`, `tests/e2e/chrome-devtools-disclaimers.test.ts`, `tests/e2e/chrome-devtools-start-stop.test.ts`, `tests/e2e/chrome-devtools-status.test.ts` | Renamed to `opera-devtools-*`; they drive the Opera-named bin | -| `tests/e2e/telemetry.test.ts` | Deleted — Opera forces telemetry off, so there is no upload path left to assert | -| `AGENTS.md` | Deleted in favour of Opera's own agent docs | -| `server.json`, `scripts/verify-server-json-version.ts`, `.github/workflows/publish-to-mcp-registry-on-tag.yml` | Deleted — the fork publishes to npm only, never to the MCP registry | +| Path | What we did | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tests/e2e/chrome-devtools-commands.test.ts`, `tests/e2e/chrome-devtools-disclaimers.test.ts`, `tests/e2e/chrome-devtools-start-stop.test.ts`, `tests/e2e/chrome-devtools-status.test.ts` | Renamed to `opera-devtools-*`; they drive the Opera-named bin | +| `tests/e2e/telemetry.test.ts` | Deleted — Opera forces telemetry off, so there is no upload path left to assert | +| `AGENTS.md` | Deleted in favour of Opera's own agent docs | +| `.github/workflows/pre-release.yml` | Deleted — it triggered on `release-please-*` branches, which no longer exist, and a separate workflow cannot gate `npm publish`; its check now runs inside `publish-to-npm-on-tag.yml` | +| `server.json`, `scripts/verify-server-json-version.ts`, `.github/workflows/publish-to-mcp-registry-on-tag.yml` | Deleted — the fork publishes to npm only, never to the MCP registry | ### Generated — never hand-merge, always regenerate diff --git a/scripts/verify-npm-package.js b/scripts/verify-npm-package.js index 600b3af..a2aea47 100644 --- a/scripts/verify-npm-package.js +++ b/scripts/verify-npm-package.js @@ -2,40 +2,85 @@ * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 + * + * Modified by Opera Software AS. */ +// Verifies the tarball npm would publish, and that it carries the version the +// release tag names. Runs immediately before `npm publish`, which cannot be +// undone: a version published from a broken tarball can only be abandoned. +// +// Uses `npm pack` rather than `npm publish --dry-run` so the check never talks +// to the registry — no auth, and no failure once the version already exists. + import {execSync} from 'node:child_process'; +import fs from 'node:fs'; + +const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8')); + +/** Strips the leading `./` npm allows in `bin` targets. */ +function normalize(entry) { + return entry.replace(/^\.\//, ''); +} + +function packedFiles() { + const output = execSync('npm pack --dry-run --json', { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + // `prepare` writes to stdout before npm does, so skip to the JSON. + const start = output.indexOf('['); + if (start === -1) { + throw new Error(`npm pack produced no JSON:\n${output}`); + } + const [tarball] = JSON.parse(output.slice(start)); + return tarball; +} -// Checks that the select build files are present using `npm publish --dry-run`. -function verifyPackageContents() { - try { - const output = execSync('npm publish --dry-run --json --silent', { - encoding: 'utf8', - }); - // skip non-JSON output from prepare. - const data = JSON.parse(output.substring(output.indexOf('{'))); - const files = data['chrome-devtools-mcp'].files.map(f => f.path); - // Check some important files. - const requiredPaths = [ - 'build/src/index.js', - 'build/src/third_party/index.js', - ]; - for (const requiredPath of requiredPaths) { - const hasBuildFolder = files.some(path => path.startsWith(requiredPath)); - if (!hasBuildFolder) { - console.error( - `Assertion Failed: "${requiredPath}" not found in tarball.`, - ); - process.exit(1); - } - } - console.log( - `npm publish --dry-run contained ${JSON.stringify(requiredPaths)}`, +function verifyContents(tarball) { + const packed = tarball.files.map(file => file.path); + // Derive the entry points from package.json so a renamed bin cannot ship + // missing, and keep the two module roots upstream already checked. + const required = [ + 'build/src/index.js', + 'build/src/third_party/index.js', + ...Object.values(packageJson.bin ?? {}).map(normalize), + ]; + + const missing = required.filter(path => !packed.includes(path)); + if (missing.length) { + throw new Error( + `tarball is missing ${missing.length} required file(s):\n` + + missing.map(path => ` ${path}`).join('\n') + + `\n It packed ${tarball.entryCount} entries. Did \`npm run bundle\` run?`, ); - } catch (err) { - console.error('failed to parse npm publish output', err); - process.exit(1); } + console.log( + `tarball ${tarball.filename} contains all ${required.length} required entry point(s) of ${tarball.entryCount} packed.`, + ); } -verifyPackageContents(); +function verifyReleaseTag(tarball) { + const tag = process.env['RELEASE_TAG']; + if (!tag) { + return; + } + const expected = `${packageJson.name}-v${packageJson.version}`; + if (tag !== expected) { + throw new Error( + `tag '${tag}' does not name the version being published.\n` + + ` package.json is ${packageJson.version}, so the tag must be '${expected}'.\n` + + ` Bump package.json, package-lock.json and src/version.ts, then retag.`, + ); + } + console.log(`tag ${tag} matches ${tarball.name}@${tarball.version}.`); +} + +try { + const tarball = packedFiles(); + verifyContents(tarball); + verifyReleaseTag(tarball); +} catch (error) { + console.error(`\n✗ ${error instanceof Error ? error.message : error}\n`); + process.exit(1); +}