Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions .github/workflows/publish-dev.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
69 changes: 69 additions & 0 deletions internal/npm-prerelease.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,9 @@
},
"publishConfig": {
"access": "public",
"provenance": true
"registry": "https://registry.npmjs.org/",
"tag": "dev",
"provenance": false
},
"repository": {
"type": "git",
Expand Down
61 changes: 61 additions & 0 deletions scripts/check-prerelease.mjs
Original file line number Diff line number Diff line change
@@ -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}`)
85 changes: 85 additions & 0 deletions test/release/check-prerelease.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
publishConfig: {tag: string}
}
type TestLock = {packages: Record<string, {integrity?: string}>}

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')
})
})