From 293a3ecf2d6f989b657de841c32ef0918aad3566 Mon Sep 17 00:00:00 2001 From: luke-speechify <289678208+luke-speechify@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:04:58 +0100 Subject: [PATCH] ci(release): release-please + provenance npm publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replicate the release pipeline from the @speechify/api TypeScript SDK, adapted for this tsup CLI (no Fern-generated version literals). - release-please.yml: compile + test on push/PR, release-please on push to main, then publish with npm provenance (OIDC trusted publishing, no token). alpha/beta tags publish to matching dist-tags. - manual-publish.yml: workflow_dispatch fallback (and the first release). - Publish asserts every version-bearing property agrees with the release tag: package.json, the built `dist/bin.js --version`, and the repository.url casing (npm provenance rejects a case-inexact URL with 422). - release-please-config.json: release-type node, pre-1.0 semantics enabled — bump-minor-pre-major + bump-patch-for-minor-pre-major, so breaking changes bump minor and features bump patch while on 0.x (nothing jumps to 1.0.0). - Pin packageManager to pnpm@10.33.4 for corepack. Requires a one-time npm trusted-publisher config for @speechify/cli (GitHub Actions, repo Speechify-AI/cli). First publish goes via manual-publish. --- .github/workflows/manual-publish.yml | 88 ++++++++++++ .github/workflows/release-please.yml | 204 +++++++++++++++++++++++++++ .release-please-manifest.json | 3 + package.json | 1 + release-please-config.json | 13 ++ 5 files changed, 309 insertions(+) create mode 100644 .github/workflows/manual-publish.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml new file mode 100644 index 0000000..2fef06f --- /dev/null +++ b/.github/workflows/manual-publish.yml @@ -0,0 +1,88 @@ +name: manual-publish + +# Manual, one-off publisher for an already-tagged release whose automatic +# publish did not run (or failed) — and the way to make the very first release, +# since the automatic publish only fires once release-please cuts a release. +# Checks out an explicit tag/ref, asserts package.json version + repository.url, +# then publishes to npm with provenance. +on: + workflow_dispatch: + inputs: + ref: + description: "Git tag/ref to publish (e.g. 0.1.0)" + required: true + default: "0.1.0" + expected_version: + description: "Version package.json MUST declare" + required: true + default: "0.1.0" + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout ref + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + - name: Set up node + uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + - name: Enable corepack + run: corepack enable + # The expected repository URL is derived from the repo this workflow runs + # in, so a rename or a re-org can never leave a stale literal here. + - name: Assert version + repository casing + env: + EXPECTED_VERSION: ${{ inputs.expected_version }} + EXPECTED_REPOSITORY: ${{ github.repository }} + run: | + cat > "${RUNNER_TEMP}/assert-manual-publish.cjs" <<'NODE' + const fs = require("node:fs"); + const path = require("node:path"); + + const packageJson = JSON.parse( + fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"), + ); + const expectedVersion = process.env.EXPECTED_VERSION; + const expectedRepositoryUrl = `git+https://github.com/${process.env.EXPECTED_REPOSITORY}.git`; + + const failures = []; + if (packageJson.version !== expectedVersion) { + failures.push( + `package.json version is "${packageJson.version}", expected "${expectedVersion}"`, + ); + } + if (packageJson.repository?.url !== expectedRepositoryUrl) { + failures.push( + `package.json repository.url is "${packageJson.repository?.url}", expected ` + + `"${expectedRepositoryUrl}" — npm publish --provenance rejects a ` + + `case-inexact URL with HTTP 422`, + ); + } + + console.log(`package.json version: ${packageJson.version}`); + console.log(`package.json repository.url: ${packageJson.repository?.url}`); + console.log(`expected version: ${expectedVersion}`); + console.log(`expected repository.url: ${expectedRepositoryUrl}`); + + if (failures.length > 0) { + console.error("\nRefusing to publish:"); + for (const failure of failures) { + console.error(` - ${failure}`); + } + process.exit(1); + } + NODE + node "${RUNNER_TEMP}/assert-manual-publish.cjs" + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build + run: pnpm build + - name: Publish to npm + run: npm publish --provenance --access public diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..471aaaa --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,204 @@ +name: release-please + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + compile: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + - name: Set up node + uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Enable corepack + run: corepack enable + # `pnpm build` is tsup, which emits but does not type-check, so the + # type-check runs here too — the SDK's `pnpm build` is `tsc` and catches + # this for free; this keeps the compile job an equivalent gate. + - name: Compile + run: pnpm install --frozen-lockfile && pnpm typecheck && pnpm build + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + - name: Set up node + uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Enable corepack + run: corepack enable + - name: Test + run: pnpm install --frozen-lockfile && pnpm test + + release-please: + needs: [compile, test] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - name: Run release-please + id: release + uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + publish: + needs: release-please + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout repo + uses: actions/checkout@v4 + - name: Set up node + uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + - name: Enable corepack + run: corepack enable + - name: Install dependencies + run: pnpm install --frozen-lockfile + # release-please (release-type "node") already bumped package.json on the + # release commit, and tsup bakes that version into dist/bin.js at build + # time (__CLI_VERSION__), so nothing needs stamping here — package.json is + # the single source of truth. Build BEFORE asserting so the assertion can + # check dist/, which is what npm actually ships. + - name: Build + run: pnpm build + # Every version-bearing property must agree with the release tag before + # anything reaches npm: package.json, the built artifact's own --version + # output, and the repository URL casing (npm's provenance check rejects a + # case-inexact URL with HTTP 422). + - name: Assert versions match release tag + env: + TAG_NAME: ${{ needs.release-please.outputs.tag_name }} + EXPECTED_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + cat > "${RUNNER_TEMP}/assert-release-versions.cjs" <<'NODE' + const fs = require("node:fs"); + const path = require("node:path"); + const { execFileSync } = require("node:child_process"); + + const fail = (message) => { + console.error(`::error::${message}`); + process.exit(1); + }; + + // include-v-in-tag is false, so the tag is bare semver. A leading "v" is + // tolerated so flipping that setting later cannot fail a valid release, and + // a prerelease/build suffix (0.1.0-alpha.1) is compared verbatim. + const BARE_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + + const rawTag = (process.env.TAG_NAME || "").trim(); + if (!rawTag) { + fail("TAG_NAME is empty — refusing to publish an unidentified release"); + } + const tag = rawTag.startsWith("v") ? rawTag.slice(1) : rawTag; + if (!BARE_SEMVER.test(tag)) { + fail(`tag "${rawTag}" is not bare semver — refusing to publish`); + } + + const readJsonOrFail = (relativePath, hint) => { + const absolutePath = path.join(process.cwd(), relativePath); + if (!fs.existsSync(absolutePath)) { + fail(`${relativePath} does not exist — ${hint}`); + } + try { + return JSON.parse(fs.readFileSync(absolutePath, "utf8")); + } catch (error) { + return fail(`cannot parse ${relativePath}: ${error.message}`); + } + }; + + const packageJson = readJsonOrFail("package.json", "the checkout is incomplete"); + + // The built binary carries the version tsup baked in (__CLI_VERSION__). + // `--version` is the artifact actually shipped answering for itself — the + // strongest check that dist/ isn't stale from an earlier build. + if (!fs.existsSync(path.join(process.cwd(), "dist/bin.js"))) { + fail("dist/bin.js does not exist — the Build step did not produce it"); + } + let binaryVersion; + try { + binaryVersion = execFileSync(process.execPath, ["dist/bin.js", "--version"], { + encoding: "utf8", + }).trim(); + } catch (error) { + return fail(`\`node dist/bin.js --version\` failed: ${error.message}`); + } + + const observedVersions = [ + ["package.json version", packageJson.version], + ["dist/bin.js --version", binaryVersion], + ]; + + // npm resolves repository.url to a repo slug for provenance and rejects a + // case-inexact one with HTTP 422, so compare case-sensitively. + const expectedRepositoryUrl = `https://github.com/${process.env.EXPECTED_REPOSITORY}`; + const declaredRepositoryUrl = String(packageJson.repository?.url) + .replace(/^git\+/, "") + .replace(/\.git$/, ""); + + console.log(`release tag: ${rawTag} (normalised: ${tag})`); + for (const [label, version] of observedVersions) { + console.log(` [${version === tag ? "ok" : "MISMATCH"}] ${label}: ${version}`); + } + console.log(`package.json repository.url: ${packageJson.repository?.url}`); + console.log(`expected repository.url: ${expectedRepositoryUrl}`); + + const failures = observedVersions + .filter(([, version]) => version !== tag) + .map(([label, version]) => `${label} is "${version}", expected "${tag}"`); + + if (declaredRepositoryUrl !== expectedRepositoryUrl) { + failures.push( + `package.json repository.url resolves to "${declaredRepositoryUrl}", expected ` + + `"${expectedRepositoryUrl}" — npm publish --provenance rejects a ` + + `case-inexact URL with HTTP 422`, + ); + } + + if (failures.length > 0) { + console.error("\nRefusing to publish:"); + for (const failure of failures) { + console.error(` - ${failure}`); + } + process.exit(1); + } + + console.log(`\nAll version-bearing properties agree with ${tag}.`); + NODE + node "${RUNNER_TEMP}/assert-release-versions.cjs" + - name: Publish to npm + env: + TAG_NAME: ${{ needs.release-please.outputs.tag_name }} + run: | + if [[ "$TAG_NAME" == *alpha* ]]; then + npm publish --provenance --access public --tag alpha + elif [[ "$TAG_NAME" == *beta* ]]; then + npm publish --provenance --access public --tag beta + else + npm publish --provenance --access public + fi diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..b985ff6 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.1" +} diff --git a/package.json b/package.json index 18d9cfd..ccf7125 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.0.1", "description": "SpeechifyAI command-line companion to the developer console — synthesize speech and manage voices from your terminal.", "type": "module", + "packageManager": "pnpm@10.33.4", "bin": { "speechify": "./dist/bin.js" }, diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..574ce63 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "node", + "include-v-in-tag": false, + "include-component-in-tag": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "packages": { + ".": { + "package-name": "@speechify/cli" + } + } +}