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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on:
pull_request:
push:
branches:
- dev
- main

permissions:
Expand All @@ -29,3 +28,4 @@ jobs:
cache: npm
- run: npm ci
- run: npm run verify
- run: node --test scripts/release-evidence.test.mjs
163 changes: 78 additions & 85 deletions .github/workflows/publish-dev.yml
Original file line number Diff line number Diff line change
@@ -1,122 +1,115 @@
name: Publish CLI dev prerelease

name: Publish accepted CLI packages
on:
schedule:
- cron: '30 2 * * *'
workflow_dispatch:
inputs:
commit:
description: Full commit SHA at the current dev head
required: true
type: string
channel:
type: choice
description: Package channel from main
options: [nightly, stable]
default: nightly
version:
description: Exact prerelease version in package.json
required: true
type: string

description: Exact x.y.z for stable; leave empty for nightly
permissions:
contents: read

concurrency:
group: cli-npm-dev
group: cli-publication
cancel-in-progress: false

jobs:
package:
if: github.ref == 'refs/heads/dev'
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
tarball: ${{ steps.pack.outputs.tarball }}
sha256: ${{ steps.pack.outputs.sha256 }}
publish: ${{ steps.gate.outputs.publish }}
version: ${{ steps.gate.outputs.version }}
sdk: ${{ steps.gate.outputs.sdk }}
env:
RELEASE_CHANNEL: ${{ inputs.channel || 'nightly' }}
STABLE_VERSION: ${{ inputs.version }}
RELEASE_OUTPUT: .release/packs
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
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
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
with:
node-version: 24.15.0
node-version: 24
registry-url: https://registry.npmjs.org/
package-manager-cache: false
- name: Check release manifest and lockfile
- name: Require an exact published SDK with matching API acceptance
id: gate
run: node scripts/prepare-release.mjs
- if: steps.gate.outputs.publish == 'true'
run: |
npm ci
npm run verify
node --test scripts/release-evidence.test.mjs
- if: steps.gate.outputs.publish == 'true'
name: Pack and smoke-test the accepted CLI and exact SDK
env:
RELEASE_VERSION: ${{ inputs.version }}
run: node scripts/check-prerelease.mjs "$RELEASE_VERSION"
- name: Verify published SDK artifact
RELEASE_VERSION: ${{ steps.gate.outputs.version }}
SDK_VERSION: ${{ steps.gate.outputs.sdk }}
run: |
set -euo pipefail
npm version "$RELEASE_VERSION" --no-git-tag-version --ignore-scripts --allow-same-version
npm run build
cp "$RELEASE_OUTPUT/release-contract.json" dist/release-contract.json
npm pack --ignore-scripts --json --pack-destination "$RELEASE_OUTPUT" > "$RELEASE_OUTPUT/pack.json"
mkdir -p .release/smoke
npm install --prefix .release/smoke --ignore-scripts "$(pwd)/$RELEASE_OUTPUT/polymorfa-cli-$RELEASE_VERSION.tgz"
.release/smoke/node_modules/.bin/polymorfa version --json > .release/smoke/version.json
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')
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
const versions = JSON.parse(readFileSync('.release/smoke/version.json'));
assert.equal(versions.cli, process.env.RELEASE_VERSION);
assert.equal(versions.sdk, process.env.SDK_VERSION);
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
- if: steps.gate.outputs.publish == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: cli-prerelease
path: ${{ steps.pack.outputs.tarball }}
name: accepted-cli-packs
include-hidden-files: true
path: .release/packs
if-no-files-found: error
retention-days: 7

retention-days: 90
publish:
needs: package
if: needs.package.outputs.publish == 'true'
runs-on: ubuntu-latest
environment: npm-prerelease
environment: ${{ inputs.channel == 'stable' && 'npm-stable' || 'npm-prerelease' }}
permissions:
contents: read
id-token: write
env:
RELEASE_CHANNEL: ${{ inputs.channel || 'nightly' }}
RELEASE_OUTPUT: .release/packs
steps:
- uses: actions/setup-node@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
with:
node-version: 24.15.0
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
with:
node-version: 24
registry-url: https://registry.npmjs.org/
package-manager-cache: false
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
name: cli-prerelease
- name: Publish the verified tarball to the dev tag
name: accepted-cli-packs
include-hidden-files: true
path: .release/packs
- run: npm install --global npm@11.6.2
- name: Publish exact verified artifact and verify registry integrity
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
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node scripts/publish-release.mjs
- name: Save release stage evidence for the later Linear bot
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: cli-publication-receipts
path: |
.release/packs/release-contract.json
.release/packs/stage-events.json
if-no-files-found: error
retention-days: 90
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ coverage/
*.tgz
.sdk-source/
.DS_Store

.release/
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2565,3 +2565,7 @@ Disabled Analytics returns no business aggregates.
These commands use the pinned SDK's `analytics.get` and `analytics.metrics`
methods and shared response types. A CLI release containing these commands and
an API deployment exposing Analytics are separate requirements.

## Package release workflow

CLI development and release PRs target `main`. Nightly packages use the `dev` npm tag after matching staging API acceptance; stable packages follow production acceptance. Each package pins an exact accepted SDK version. See [the release guide](RELEASING.md).
37 changes: 37 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# CLI releases from main

All CLI PRs target `main`. The preserved `dev` branch is no longer a publication
trigger. The daily nightly runs at 02:30 UTC and uses npm's `dev` tag. Stable
releases require an explicit `x.y.z` and the protected `npm-stable` environment.
The workflow remains `publish-dev.yml` to preserve its publisher identity.

The SDK publishes first. A CLI build selects an exact SDK version from the
matching package channel, downloads its registry artifact and verifies its
integrity. Its embedded contract receipt must prove matching staging acceptance
for a nightly or production acceptance for stable. Missing SDK provenance,
failed scenarios, different source/spec/environment or an unavailable SDK block
publication. Nightly timestamps never advance `Polymorfa-Version`.

The build pins that exact SDK version in its disposable manifest and lockfile,
runs verification, then writes the package version. The packed CLI is installed
into a clean consumer and its `version --json` output must match both versions.
The tarball includes `dist/release-contract.json` binding CLI, SDK and API
revisions. A repeated version must have matching registry integrity. An
unchanged CLI source plus the same exact SDK skips the next nightly.

Publication emits feature-to-ticket stage receipts for a later Linear bot. A
successful nightly does not mean production availability or authorize closing
feature tickets. Feature definitions remain in Notion; engineering issues stay
in Linear. The monorepo feature registry owns their links.

Keep the existing `npm-prerelease` credentials/trusted publishing and configure
`npm-stable` for approved main releases. The private CLI repository retains
`provenance: false` for npm's public source attestation; the embedded acceptance
receipt still supplies contract evidence. Use the existing token fallback when
trusted publishing is unavailable. Do not expose source credentials in packages.

Run `npm ci`, `npm run verify`, `node --test scripts/release-evidence.test.mjs`
and Actionlint before merge. These checks prove local packaging behavior; hosted
acceptance and npm publication remain separate gates. Roll back an affected
channel to a previously verified CLI and SDK pair through an authorized release
operation. Never replace a published version.
8 changes: 7 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import tseslint from 'typescript-eslint'

export default tseslint.config(
{
ignores: ['.sdk-source/**', 'coverage/**', 'dist/**', 'node_modules/**'],
ignores: [
'.sdk-source/**',
'.release/**',
'coverage/**',
'dist/**',
'node_modules/**',
],
},
{
linterOptions: {reportUnusedDisableDirectives: 'error'},
Expand Down
114 changes: 114 additions & 0 deletions scripts/prepare-release.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import console from 'node:console'
import process from 'node:process'
import assert from 'node:assert/strict'
import {createHash} from 'node:crypto'
import {execFileSync} from 'node:child_process'
import {mkdirSync, readFileSync, writeFileSync} from 'node:fs'
import {join, resolve} from 'node:path'
import {registryEvidence, validateSdk} from './release-evidence.mjs'

try {
assert.equal(
process.env.GITHUB_REF,
'refs/heads/main',
'CLI releases run only from main',
)
const channel = process.env.RELEASE_CHANNEL
assert.ok(['nightly', 'stable'].includes(channel))
const tag = channel === 'stable' ? 'latest' : 'dev'
const output = resolve(process.env.RELEASE_OUTPUT)
mkdirSync(output, {recursive: true, mode: 0o700})
const sdk = await registryEvidence('@polymorfa/sdk', tag, output)
assert.ok(
sdk?.provenance,
'SDK publication with matching API acceptance must finish first',
)
validateSdk(sdk.provenance, sdk.version, channel)
const files = execFileSync('git', [
'ls-files',
'-z',
'src',
'bin',
'scripts',
'skills',
'package.json',
'package-lock.json',
])
.toString()
.split('\0')
.filter(Boolean)
.sort()
const digest = createHash('sha256')
for (const file of files)
digest
.update(file + '\0')
.update(readFileSync(file))
.update('\0')
digest.update(sdk.version).update(sdk.integrity)
const fingerprint = digest.digest('hex')
const previous = await registryEvidence('@polymorfa/cli', tag, output)
if (
channel === 'nightly' &&
previous?.provenance?.fingerprint === fingerprint
) {
writeFileSync(process.env.GITHUB_OUTPUT, 'publish=false\n', {flag: 'a'})
console.log('CLI and its exact SDK are already published; skipping nightly')
} else {
const pkg = JSON.parse(readFileSync('package.json'))
const base = pkg.version.split('-')[0]
const version =
channel === 'stable'
? process.env.STABLE_VERSION
: `${base}-dev.${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`
assert.match(
version ?? '',
channel === 'stable'
? /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/
: /^\d+\.\d+\.\d+-dev\.\d+$/,
)
if (previous?.version === version)
assert.equal(
previous.provenance?.fingerprint,
fingerprint,
'CLI version belongs to different source',
)
const sourceSha = execFileSync('git', ['rev-parse', 'HEAD'], {
encoding: 'utf8',
}).trim()
const provenance = {
schemaVersion: 1,
repository: 'polymorfa/cli',
sourceSha,
fingerprint,
channel,
packageVersion: version,
sdkVersion: sdk.version,
sdkIntegrity: sdk.integrity,
sdk: sdk.provenance,
}
writeFileSync(
join(output, 'release-contract.json'),
JSON.stringify(provenance, null, 2) + '\n',
)
// Exact accepted SDK selection is a package build input; branches remain main.
execFileSync(
'npm',
[
'install',
'--package-lock-only',
'--ignore-scripts',
'--save-exact',
`@polymorfa/sdk@${sdk.version}`,
],
{stdio: 'inherit'},
)
writeFileSync(
process.env.GITHUB_OUTPUT,
`publish=true\nversion=${version}\nsdk=${sdk.version}\n`,
{flag: 'a'},
)
}
} catch (error) {
console.error(error.message)
process.exitCode = 1
}
Loading