diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml new file mode 100644 index 00000000..8e8b9d4f --- /dev/null +++ b/.github/workflows/publish-dev.yml @@ -0,0 +1,122 @@ +name: Publish CLI dev prerelease + +on: + workflow_dispatch: + inputs: + commit: + description: Full commit SHA at the current dev head + required: true + type: string + version: + description: Exact prerelease version in package.json + required: true + type: string + +permissions: + contents: read + +concurrency: + group: cli-npm-dev + cancel-in-progress: false + +jobs: + package: + if: github.ref == 'refs/heads/dev' + runs-on: ubuntu-latest + outputs: + tarball: ${{ steps.pack.outputs.tarball }} + sha256: ${{ steps.pack.outputs.sha256 }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.commit }} + fetch-depth: 0 + persist-credentials: false + - name: Check immutable release source + env: + RELEASE_COMMIT: ${{ inputs.commit }} + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + [[ "$RELEASE_COMMIT" =~ ^[0-9a-f]{40}$ ]] + git fetch origin dev + test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT" + test "$(git rev-parse origin/dev)" = "$RELEASE_COMMIT" + test -z "$(git status --porcelain)" + - uses: actions/setup-node@v6 + with: + node-version: 24.15.0 + registry-url: https://registry.npmjs.org/ + package-manager-cache: false + - name: Check release manifest and lockfile + env: + RELEASE_VERSION: ${{ inputs.version }} + run: node scripts/check-prerelease.mjs "$RELEASE_VERSION" + - name: Verify published SDK artifact + run: | + set -euo pipefail + node --input-type=module <<'NODE' + import {execFileSync} from 'node:child_process' + import {readFileSync} from 'node:fs' + const lock = JSON.parse(readFileSync('package-lock.json')) + const sdk = lock.packages['node_modules/@polymorfa/sdk'] + const actual = JSON.parse(execFileSync('npm', ['view', `@polymorfa/sdk@${sdk.version}`, 'dist.integrity', '--json'], {encoding: 'utf8'})) + if (actual !== sdk.integrity) throw new Error('Published SDK integrity differs from the lockfile') + NODE + - run: npm ci + - run: npm run verify + - name: Pack and smoke test the release artifact + id: pack + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + npm pack --ignore-scripts --json > pack.json + tarball="$(node --input-type=module -e "import {readFileSync} from 'node:fs'; console.log(JSON.parse(readFileSync('pack.json'))[0].filename)")" + test "$tarball" = "polymorfa-cli-$RELEASE_VERSION.tgz" + mkdir -p "$RUNNER_TEMP/cli-smoke" + npm install --prefix "$RUNNER_TEMP/cli-smoke" --ignore-scripts "$(pwd)/$tarball" + "$RUNNER_TEMP/cli-smoke/node_modules/.bin/polymorfa" version --json > "$RUNNER_TEMP/cli-smoke/version.json" + node --input-type=module -e "import {readFileSync} from 'node:fs'; const v=JSON.parse(readFileSync(process.argv[1])); if(v.cli!==process.argv[2] || v.sdk!==process.argv[3]) process.exit(1)" "$RUNNER_TEMP/cli-smoke/version.json" "$RELEASE_VERSION" "$(node -p "require('./package.json').dependencies['@polymorfa/sdk']")" + echo "tarball=$tarball" >> "$GITHUB_OUTPUT" + echo "sha256=$(sha256sum "$tarball" | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v4 + with: + name: cli-prerelease + path: ${{ steps.pack.outputs.tarball }} + if-no-files-found: error + retention-days: 7 + + publish: + needs: package + runs-on: ubuntu-latest + environment: npm-prerelease + permissions: + contents: read + id-token: write + steps: + - uses: actions/setup-node@v6 + with: + node-version: 24.15.0 + registry-url: https://registry.npmjs.org/ + package-manager-cache: false + - uses: actions/download-artifact@v4 + with: + name: cli-prerelease + - name: Publish the verified tarball to the dev tag + env: + RELEASE_VERSION: ${{ inputs.version }} + EXPECTED_TARBALL: ${{ needs.package.outputs.tarball }} + EXPECTED_SHA256: ${{ needs.package.outputs.sha256 }} + run: | + set -euo pipefail + test "$EXPECTED_TARBALL" = "polymorfa-cli-$RELEASE_VERSION.tgz" + echo "$EXPECTED_SHA256 $EXPECTED_TARBALL" | sha256sum --check - + node -e "const v=process.versions.node.split('.').map(Number); if(v[0]<22 || (v[0]===22 && v[1]<14)) process.exit(1)" + node -e "const v=process.argv[1].split('.').map(Number); if(v[0]<11 || (v[0]===11 && (v[1]<5 || (v[1]===5 && v[2]<1)))) process.exit(1)" "$(npm --version)" + npm view "@polymorfa/cli" version --json > /dev/null + if npm view "@polymorfa/cli@$RELEASE_VERSION" version --json > /dev/null 2>&1; then + echo "Version already exists in npm" >&2 + exit 1 + fi + npm publish "$EXPECTED_TARBALL" --access public --tag dev --provenance=false --ignore-scripts diff --git a/README.md b/README.md index 3845eaa7..cbc92d40 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ The official command-line interface for Polymorfa. The package is under active development. `@polymorfa/cli` is not published to -npm yet, so `npm install --global @polymorfa/cli` does not work. Once the CLI is -published, that command will be the supported installation. Until then, install -from the private source repository, which requires repository access. +npm yet. When a development prerelease is available, install it explicitly with +`npm install --global @polymorfa/cli@dev`. Until then, install from the private +source repository, which requires repository access. ## Requirements diff --git a/internal/npm-prerelease.md b/internal/npm-prerelease.md new file mode 100644 index 00000000..39e9d929 --- /dev/null +++ b/internal/npm-prerelease.md @@ -0,0 +1,69 @@ +# CLI npm development releases + +The source repository is private. The public npm package is `@polymorfa/cli`; +the supported prerelease install selector is `@dev`. Publishing a package is a +separate decision from merging code. The release owner chooses the version and +authorizes each npm publication after inspecting the exact commit, package +contents, SDK pin, and customer availability. + +## First-package bootstrap + +The package is not yet in npm. npm trusted publishing is configured on an +existing package, so the npm organization owner must create the first package +with an owner credential and any required two-factor challenge. The workflow in +this repository deliberately refuses to publish while the package is absent. + +**The first publication may also create npm's `latest` dist-tag even when the +command specifies `--tag dev`.** The owner must decide whether that exposure is +acceptable before the bootstrap. Do not assume the `dev` tag isolates the first +version. Do not create the first version or move `latest` as part of preparing a +pull request. + +For an approved bootstrap, use a reviewed `dev` commit with a unique package +version and exact published SDK dependency. Run `npm ci`, `npm run verify`, pack +the repository with `npm pack --ignore-scripts`, inspect the tarball, and test +installation from that tarball in a clean directory on supported Node versions. +The owner then publishes that exact tarball publicly with the `dev` tag and +provenance disabled. Use an interactive owner session or a short-lived credential; +do not place an npm write token in source or GitHub Actions. Verify the registry +version, integrity, `dev` and `latest` tags, and a fresh `@dev` install after the +publish. Record any `latest` exposure in the release decision. + +## Subsequent development releases + +After bootstrap, the npm organization owner configures a trusted publisher for +`@polymorfa/cli` on npm: + +- GitHub organization `polymorfa`, repository `cli`; +- workflow filename `publish-dev.yml`; +- GitHub environment `npm-prerelease`; +- direct `npm publish` allowed. + +Create the `npm-prerelease` environment in GitHub with a required reviewer and +restrict it to `dev`. The workflow uses GitHub-hosted Ubuntu runners, an exact +`dev` head commit and the package version supplied at dispatch. The packaging +job has no OIDC permission. It verifies the SDK registry integrity, runs the +project checks, packs once, and smoke tests the tarball. The publish job receives +only that artifact, checks its SHA-256 digest, and requests a short-lived npm +credential through OIDC. It refuses an existing version and publishes with +`--tag dev`. The package's `publishConfig` also defaults to `dev` so a local +publish does not silently choose the default tag. + +Because this GitHub repository is private, npm cannot issue a public provenance +attestation for its packages. Trusted publishing still removes the need for a +persistent npm write token in the workflow. Keep provenance disabled while the +source repository is private; reassess it if repository visibility changes. + +Before dispatch, merge a version and lockfile update to `dev`, confirm its +exact-head CI, verify the SDK artifact and its dependency integrity, then review +the release's API, SDK, CLI, public docs, and feature availability. Dispatch +`Publish CLI dev prerelease` on `dev` with the full 40-character `dev` commit +SHA and the matching package version. The workflow fails if `dev` advances +before it checks the source. After approval and publish, inspect the live npm +version and tags, install `@polymorfa/cli@dev` in a clean environment, and run +`polymorfa version --json`. Customer-facing docs and changelog must reflect the +version customers can actually install. Production availability and stable +`latest` remain separate decisions. + +Do not rerun a failed or ambiguous publish with a new version until the registry +is checked. npm versions cannot be reused after publication. diff --git a/package.json b/package.json index f92f1306..03953d32 100644 --- a/package.json +++ b/package.json @@ -298,7 +298,9 @@ }, "publishConfig": { "access": "public", - "provenance": true + "registry": "https://registry.npmjs.org/", + "tag": "dev", + "provenance": false }, "repository": { "type": "git", diff --git a/scripts/check-prerelease.mjs b/scripts/check-prerelease.mjs new file mode 100644 index 00000000..412d3871 --- /dev/null +++ b/scripts/check-prerelease.mjs @@ -0,0 +1,61 @@ +import {readFileSync} from 'node:fs' +import console from 'node:console' +import process from 'node:process' +import {URL} from 'node:url' + +const expectedVersion = process.argv[2] +const manifest = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url)), +) +const lock = JSON.parse( + readFileSync(new URL('../package-lock.json', import.meta.url)), +) +const sdk = manifest.dependencies['@polymorfa/sdk'] + +function fail(message) { + throw new Error(`CLI prerelease check: ${message}`) +} + +if (!/^0\.\d+\.\d+-dev\.\d+$/.test(expectedVersion ?? '')) { + fail('provide an explicit 0.x dev prerelease version') +} +if ( + manifest.name !== '@polymorfa/cli' || + manifest.version !== expectedVersion +) { + fail('package name or version does not match the requested release') +} +if (lock.name !== manifest.name || lock.version !== expectedVersion) { + fail('root lockfile identity does not match package.json') +} +if ( + lock.packages[''].name !== manifest.name || + lock.packages[''].version !== expectedVersion +) { + fail('root lockfile package does not match package.json') +} +if (!/^0\.\d+\.\d+-dev\.\d+$/.test(sdk)) { + fail('the SDK must be an exact published dev prerelease') +} +if (lock.packages[''].dependencies['@polymorfa/sdk'] !== sdk) { + fail('the root lockfile does not pin the SDK version') +} +const installedSdk = lock.packages['node_modules/@polymorfa/sdk'] +if ( + installedSdk?.version !== sdk || + !installedSdk.integrity?.startsWith('sha512-') +) { + fail('the lockfile lacks the exact SDK artifact and integrity') +} +if ( + manifest.publishConfig?.access !== 'public' || + manifest.publishConfig?.registry !== 'https://registry.npmjs.org/' || + manifest.publishConfig?.tag !== 'dev' || + manifest.publishConfig?.provenance !== false +) { + fail( + 'publication must target the public npm dev tag without private-source provenance', + ) +} + +console.log(`${manifest.name}@${expectedVersion} pins @polymorfa/sdk@${sdk}`) diff --git a/test/release/check-prerelease.test.ts b/test/release/check-prerelease.test.ts new file mode 100644 index 00000000..1faa33ce --- /dev/null +++ b/test/release/check-prerelease.test.ts @@ -0,0 +1,85 @@ +import {spawnSync} from 'node:child_process' +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {fileURLToPath} from 'node:url' +import {afterEach, describe, expect, test} from 'vitest' + +const root = fileURLToPath(new URL('../../', import.meta.url)) +const tempDirectories: string[] = [] +type TestManifest = { + version: string + dependencies: Record + publishConfig: {tag: string} +} +type TestLock = {packages: Record} + +function runWith(mutator?: (manifest: TestManifest, lock: TestLock) => void) { + const temp = mkdtempSync(join(tmpdir(), 'polymorfa-cli-release-')) + tempDirectories.push(temp) + mkdirSync(join(temp, 'scripts')) + cpSync( + join(root, 'scripts/check-prerelease.mjs'), + join(temp, 'scripts/check-prerelease.mjs'), + ) + const manifest = JSON.parse( + readFileSync(join(root, 'package.json'), 'utf8'), + ) as TestManifest + const lock = JSON.parse( + readFileSync(join(root, 'package-lock.json'), 'utf8'), + ) as TestLock + mutator?.(manifest, lock) + writeFileSync(join(temp, 'package.json'), JSON.stringify(manifest)) + writeFileSync(join(temp, 'package-lock.json'), JSON.stringify(lock)) + return spawnSync( + process.execPath, + [join(temp, 'scripts/check-prerelease.mjs'), manifest.version], + { + encoding: 'utf8', + }, + ) +} + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) + rmSync(directory, {force: true, recursive: true}) +}) + +describe('npm prerelease guard', () => { + test('accepts the reviewed manifest and lockfile', () => { + const result = runWith() + expect(result.status).toBe(0) + expect(result.stdout).toContain('@polymorfa/sdk@0.1.0-dev.20260926005918') + }) + + test('rejects the default latest tag', () => { + const result = runWith((manifest) => { + manifest.publishConfig.tag = 'latest' + }) + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('public npm dev tag') + }) + + test('rejects SDK drift between manifest and lockfile', () => { + const result = runWith((manifest) => { + manifest.dependencies['@polymorfa/sdk'] = 'github:polymorfa/sdks#dev' + }) + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('exact published dev prerelease') + }) + + test('rejects a lockfile without the published SDK integrity', () => { + const result = runWith((_manifest, lock) => { + delete lock.packages['node_modules/@polymorfa/sdk']!.integrity + }) + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('SDK artifact and integrity') + }) +})