From 144026ca72796d3ca81b6d2e31c7cfe581875c34 Mon Sep 17 00:00:00 2001 From: Dave Oster Date: Thu, 1 Oct 2026 14:28:02 -0500 Subject: [PATCH 1/4] Automate production CLI npm releases from master --- .github/workflows/publish-npm.yml | 42 ++++++++------- AGENTS.md | 2 + docs/development.md | 2 + docs/releases.md | 67 ++++++++++++++++------- npm-alias/package.json | 4 +- package-lock.json | 4 +- package.json | 4 +- scripts/release-auto.mjs | 90 +++++++++++++++++++++++++++++++ scripts/release-plan.mjs | 47 ++++++++++++++++ src/version.ts | 2 +- tests/release.test.ts | 58 ++++++++++++++++++++ 11 files changed, 276 insertions(+), 46 deletions(-) create mode 100644 scripts/release-auto.mjs create mode 100644 scripts/release-plan.mjs create mode 100644 tests/release.test.ts diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 7d70b29..1fdf843 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -1,17 +1,8 @@ name: Publish CLI to npm on: + push: + branches: [master] workflow_dispatch: - inputs: - version: - description: Exact version already committed on master - required: true - type: string - channel: - description: npm distribution tag - required: true - default: next - type: choice - options: [next, latest] permissions: contents: read concurrency: @@ -19,24 +10,35 @@ concurrency: cancel-in-progress: false jobs: publish: + name: Publish production CLI packages if: github.ref == 'refs/heads/master' runs-on: ubuntu-latest - environment: npm + timeout-minutes: 30 + environment: + name: npm + url: https://www.npmjs.com/package/dealmachine permissions: contents: read id-token: write steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-node@v4 with: node-version: '24' registry-url: https://registry.npmjs.org - - name: Verify requested release - env: - RELEASE_VERSION: ${{ inputs.version }} - run: node --input-type=module -e "import fs from 'node:fs'; import assert from 'node:assert/strict'; assert.equal(JSON.parse(fs.readFileSync('package.json')).version, process.env.RELEASE_VERSION);" + - name: Verify trusted publishing tooling + run: node --input-type=module -e "import { execFileSync } from 'node:child_process'; import assert from 'node:assert/strict'; const v = execFileSync('npm', ['--version'], { encoding:'utf8' }).trim().split('.').map(Number); assert(v[0] > 11 || (v[0] === 11 && (v[1] > 5 || (v[1] === 5 && v[2] >= 1))), 'npm 11.5.1 or later is required for trusted publishing');" - run: npm ci - - name: Publish implementation and alias - env: - RELEASE_CHANNEL: ${{ inputs.channel }} - run: npm run release:publish -- --tag "$RELEASE_CHANNEL" + - name: Build, publish and verify implementation and alias + id: release + run: npm run release:auto:publish + - name: Retain release evidence and exact package archives + if: always() + uses: actions/upload-artifact@v4 + with: + name: cli-release-${{ github.sha }}-${{ github.run_attempt }} + path: artifacts/ + if-no-files-found: ignore + retention-days: 90 diff --git a/AGENTS.md b/AGENTS.md index e5210c0..2fb7054 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,4 +12,6 @@ Use the existing behavioral tests for changed Commands. Run `npm run check` and Set versions with `npm run release:version -- ` so the canonical package, alias and lockfile agree. A release requires an explicit channel and approved release scope. Publishing the CLI does not deploy the API, MCP server, docs site or Next app. +Merging into `master` runs automatic production npm publication of both packages once npm trusted publishing is configured. The committed stable version is a release floor; the workflow selects the next available patch and records the source SHA in both packages. Treat a master merge as a release handoff. See the release guide for retry, integrity and setup requirements. + Call CLI capabilities Commands, API capabilities Endpoints, and distributable agent instructions Playbooks. Write concrete copy and do not add em dashes to docs or comments. diff --git a/docs/development.md b/docs/development.md index e8499f4..2f38fff 100644 --- a/docs/development.md +++ b/docs/development.md @@ -18,3 +18,5 @@ For local API work, start the API in its owning checkout, then use `DM_API_URL=h The public agent plugin manifests and `skills/dealmachine` are retained in this repository. The hosted MCP server is a separate service. This extraction does not make MCP implementation or docs-site deployment part of CLI publication. From Factory, use `npm run setup:cli`, `npm run dev:cli`, `npm run cli:check` and `npm run cli:test:package`. Factory's `where cli` locates this checkout and `scripts cli` discovers its npm scripts. Read [releases](releases.md) before publishing. + +Every `master` push runs the production npm workflow. `npm run release:auto:dry-run` reads the public registry, chooses the version, validates both package archives and simulates publication without changing the registry. It restores the local version files after the run. Published packages record their exact source SHA; the automatic version does not create a source commit or tag. diff --git a/docs/releases.md b/docs/releases.md index a97dbf9..af30ada 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,47 +1,74 @@ # npm releases -`@dealmachine/cli` is the implementation and `dealmachine` is the short install alias. Both are public on npmjs.org. They must have the same version; the alias pins the exact implementation version. The version command also updates `src/version.ts`, and packed-install checks verify the reported version against the manifest. Agent plugin and hosted MCP manifest versions follow their own release lifecycle. +`@dealmachine/cli` is the implementation and `dealmachine` is the short install alias. Both are public on npmjs.org. They have the same version; the alias pins the exact implementation version. The runtime version in `src/version.ts` is updated with the package manifests, and the packed consumer check verifies it. Agent plugin and hosted MCP manifest versions follow their own release lifecycle. -The extraction candidate is `0.4.0-rc.1`; `0.3.0` remains the published baseline until a new release runs. Review the newly moved Commands against the intended API environment before promoting a stable release. +## Automatic production publication -```sh -npm run release:version -- 0.4.0-rc.1 -npm run release:dry-run -- --tag next -``` +Every push to this public repository's `master` runs `publish-npm.yml`, named **Publish CLI to npm**. The **Publish production CLI packages** job builds, checks, installs and publishes both packages to `latest`, then verifies their source SHA, integrity and distribution tags through the public registry. Factory observes that job for production build and release tracking. A failed or incomplete pair is not a successful deployment. Source merges, publication and any subsequent production regression are separate evidence. + +The committed stable version, initially `0.4.0`, sets the minimum release version. When that version or a later stable version already exists in either package, the workflow selects the next patch after the highest stable version. For example, a committed `0.4.0` with published `0.4.10` produces `0.4.11`. Set and commit a higher minor or major version with `release:version` when the change warrants it. Automatic publishing refuses a prerelease baseline. + +The job updates the version only in its temporary build checkout. Both npm manifests contain `dealmachineRelease.sourceSha`; the workflow does not create a source commit or Git tag. The source version is a floor, so it can differ from an installed package's actual runtime version. The receipt and published metadata identify the exact source and package versions. + +Releases run serially. A job whose source is no longer current `master` stops before publication. Re-running the current commit discovers any package already published for that SHA, rebuilds and compares its integrity, then publishes only the missing package. A conflicting source, changed bytes, multiple versions for one source, or a newer published stable version stops the retry. The workflow never overwrites immutable npm versions or silently moves `latest` backward. + +The workflow retains both archives and `release.json` under the `cli-release--` GitHub artifact for 90 days, including evidence available after a failure. The receipt has source SHA, workflow run ID, version, channel, integrity digests and a `verified` result. A successful job requires verification of both packages. The run summary links to both exact npm versions. + +## One-time access setup + +For **both** npm package settings, configure a GitHub trusted publisher with these exact values: -The version command updates both manifests, the runtime version and the canonical lockfile without creating a Git tag. The dry run executes checks, installs both packed archives in a clean consumer, and calls `npm publish --dry-run`. It does not publish. `release:pack` performs the same validation and leaves archives plus a `release.json` integrity record in ignored `artifacts//`. +| Setting | Value | +| --- | --- | +| Organization | `DealMachine` | +| Repository | `dealmachine-cli` | +| Workflow filename | `publish-npm.yml` | +| Environment | `npm` | +| Allowed action | Direct publication with `npm publish` | -For a local approved release, commit the reviewed changes and use: +Create the GitHub `npm` environment and restrict deployment branches to `master`. For unattended publication on a reviewed master merge, this environment must not require a second manual review. Repository branch review and checks remain the source approval gate. Do not weaken an existing environment rule without the release owner's approval. + +The job uses GitHub-hosted Ubuntu, Node 24, npm 11.5.1 or later, and `id-token: write`. No npm token belongs in GitHub secrets or chat. Trusted publication from this public repository supplies npm provenance. npm package-owner access is needed for the publisher setup, which cannot be verified from public package metadata. See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). + +After setup and merge, recover the current `master` release with: ```sh -npm run release:publish -- --tag next +gh workflow run publish-npm.yml --repo DealMachine/dealmachine-cli --ref master ``` -Publishing requires a clean checkout, authenticated npm access and an explicit `next` or `latest` tag. Prereleases cannot use `latest`. The implementation is published before the alias. Both uploads are independent registry operations: if the second fails, verify the first package's published integrity against `artifacts//release.json`, then publish only the matching alias archive with `npm publish artifacts//dealmachine-.tgz --access public --registry https://registry.npmjs.org --tag next`. Do not rebuild or replace an already published version. +Do not dispatch an older run to roll back a release. If registry access is missing, the run fails and preserves its built artifacts; configure the access above and rerun the current master workflow. -## GitHub publishing +## Validate without publication -The manual `publish-npm.yml` workflow runs only from `master`, checks the requested version, validates the packages and publishes through npm trusted publishing. It uses the GitHub environment `npm` and an OIDC token. It is not triggered by a normal commit or merge. +```sh +npm run release:auto:dry-run +``` -For **both** npm package settings, configure a GitHub trusted publisher with organization `DealMachine`, repository `dealmachine-cli`, workflow filename `publish-npm.yml`, environment `npm`, and permission to publish. Configure the GitHub `npm` environment with the team's release reviewers. Do not add tokens to the repository. npm requires CLI 11.5.1+ and Node 22.14+; the workflow uses Node 24. See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). +This reads public registry metadata, selects the candidate version, runs `check` and `test:package`, and calls `npm publish --dry-run` on each unpublished archive. It restores local version files, publishes nothing and leaves artifacts under ignored `artifacts//`. It does not prove npm publishing permission. -After the source PR is reviewed and merged, an authorized maintainer can dispatch: +For an explicit local prerelease or manually selected version: ```sh -gh workflow run publish-npm.yml --repo DealMachine/dealmachine-cli --ref master -f version=0.4.0-rc.1 -f channel=next +npm run release:version -- 0.5.0-rc.1 +npm run release:dry-run -- --tag next +npm run release:pack +# Only with authorized release scope, a clean committed checkout and npm access: +npm run release:publish -- --tag next ``` -To publish a stable version, set a new stable version, rerun validation, merge the reviewed change and dispatch with `channel=latest`. Removing `-rc` creates a new artifact; moving a tag does not rename a version. Never overwrite an existing npm version. +The manual commands retain an explicit `next` or `latest` channel; prereleases cannot use `latest`. Do not merge a prerelease version into master because its automatic production job requires a stable version. Do not run manual publication concurrently with the automatic workflow. ## Verify and recover ```sh npm view @dealmachine/cli dist-tags --json npm view dealmachine dist-tags --json -npx --yes --package=dealmachine@0.4.0-rc.1 dm --version -npx --yes --package=dealmachine@0.4.0-rc.1 dm agents playbook +npx --yes --package=dealmachine@0.4.0 dm --version +npx --yes --package=dealmachine@0.4.0 dm agents playbook ``` -Use exact versions for release verification. `eval:cold-start:published` checks the existing default npm channel; it does not select a prerelease automatically. `eval:cold-start:deployed` checks the hosted docs surface and can fail independently of a valid CLI package. +Replace `0.4.0` with the exact version from the release receipt. `eval:cold-start:published` checks the default npm channel; `eval:cold-start:deployed` checks the hosted docs independently. A package release does not deploy API, MCP, Next or the documentation site. + +The two npm uploads are separate operations. If the alias upload fails, the implementation may already be available; keep the workflow failed until both are verified. Retry the current master workflow to reuse the same source version and verify its bytes. If the source has moved, its next release can publish a new pair, while the partial version remains available for diagnosis. If bytes differ on a retry, retain the original archive and receipt for an owner; do not bypass the integrity check. -Rollback means moving the affected distribution tags for both packages back to the previously verified version, then verifying new installs. Existing installed versions do not change automatically. Publishing this client does not deploy the API, MCP, Next application, or docs site. +An authorized rollback moves both distribution tags to the previous verified version, then verifies fresh installs. Existing installed versions do not change. The automated publisher never performs that rollback. After a rollback, a new reviewed master commit receives a higher version, even though `latest` points backward. Moving a tag does not rename an immutable package version. diff --git a/npm-alias/package.json b/npm-alias/package.json index c4d76df..aa4c31a 100644 --- a/npm-alias/package.json +++ b/npm-alias/package.json @@ -1,6 +1,6 @@ { "name": "dealmachine", - "version": "0.4.0-rc.4", + "version": "0.4.0", "description": "Short install alias for the DealMachine property intelligence CLI", "author": "DealMachine", "license": "MIT", @@ -23,7 +23,7 @@ "LICENSE" ], "dependencies": { - "@dealmachine/cli": "0.4.0-rc.4" + "@dealmachine/cli": "0.4.0" }, "engines": { "node": ">=18" diff --git a/package-lock.json b/package-lock.json index 225166d..81a5713 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dealmachine/cli", - "version": "0.4.0-rc.4", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dealmachine/cli", - "version": "0.4.0-rc.4", + "version": "0.4.0", "license": "MIT", "dependencies": { "chalk": "^5.3.0", diff --git a/package.json b/package.json index bdc592b..be584be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dealmachine/cli", - "version": "0.4.0-rc.4", + "version": "0.4.0", "description": "DealMachine CLI for property intelligence, people lookup, and enrichment through the public API", "author": "DealMachine", "license": "MIT", @@ -38,6 +38,8 @@ "release:pack": "node scripts/release.mjs pack", "release:dry-run": "node scripts/release.mjs dry-run", "release:publish": "node scripts/release.mjs publish", + "release:auto:dry-run": "node scripts/release-auto.mjs dry-run", + "release:auto:publish": "node scripts/release-auto.mjs publish", "prepack": "npm run build && npm run check:artifact" }, "dependencies": { diff --git a/scripts/release-auto.mjs b/scripts/release-auto.mjs new file mode 100644 index 0000000..3cd0842 --- /dev/null +++ b/scripts/release-auto.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setTimeout } from 'node:timers/promises'; +import { planRelease, releasePackages, verifyPublishedPackage } from './release-plan.mjs'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const [mode, ...extra] = process.argv.slice(2); +assert(['dry-run', 'publish'].includes(mode) && !extra.length, 'Use release:auto:dry-run or release:auto:publish.'); +const execute = (command, args, options = {}) => execFileSync(command, args, { cwd: root, stdio: 'inherit', ...options }); +const git = (...args) => execute('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim(); +const sourceSha = git('rev-parse', 'HEAD'); +const assertCurrentMaster = () => { + assert.equal(git('ls-remote', 'origin', 'refs/heads/master').split(/\s/)[0], sourceSha, 'Source is no longer current master. Let the newest master run publish.'); +}; +if (mode === 'publish') { + assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Automatic publishing runs only in GitHub Actions; use release:publish for an authorized local release.'); + assert.equal(process.env.GITHUB_REPOSITORY, 'DealMachine/dealmachine-cli'); + assert.equal(process.env.GITHUB_REF, 'refs/heads/master'); + assert.equal(process.env.GITHUB_SHA, sourceSha); + assert(['push', 'workflow_dispatch'].includes(process.env.GITHUB_EVENT_NAME), 'Only master push and recovery dispatch may publish.'); + assert.equal(git('status', '--porcelain'), '', 'Start from the clean committed release source.'); + assertCurrentMaster(); +} + +async function registry(name) { + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, { + headers: { accept: 'application/json', 'cache-control': 'no-cache' }, + redirect: 'error', signal: AbortSignal.timeout(30_000), + }); + assert(response.ok, `Cannot read ${name} registry metadata (${response.status}). No release decision was made.`); + const metadata = await response.json(); + assert(metadata.name === name && metadata.versions && typeof metadata.versions === 'object', `Invalid registry metadata for ${name}.`); + return metadata; +} +const registries = Object.fromEntries(await Promise.all(releasePackages.map(async name => [name, await registry(name)]))); +const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); +const plan = planRelease(manifest.version, sourceSha, registries); +const changedFiles = ['package.json', 'package-lock.json', 'npm-alias/package.json', 'src/version.ts']; +const originals = new Map(changedFiles.map(name => [name, readFileSync(resolve(root, name))])); +console.log(`${mode === 'dry-run' ? 'Would publish' : 'Publishing'} both CLI packages as ${plan.version} from ${sourceSha}.`); +try { + execute(process.execPath, ['scripts/release-version.mjs', plan.version]); + for (const name of ['package.json', 'npm-alias/package.json']) { + const path = resolve(root, name); + const data = JSON.parse(readFileSync(path, 'utf8')); + data.dealmachineRelease = { sourceSha }; + writeFileSync(path, JSON.stringify(data, null, 2) + '\n'); + } + // This runs the owner's complete checks and clean consumer install before either upload. + execute(process.execPath, ['scripts/release.mjs', 'pack', '--tag', plan.tag]); + const output = resolve(root, 'artifacts', plan.version); + const record = JSON.parse(readFileSync(resolve(output, 'release.json'), 'utf8')); + Object.assign(record, plan, { repository: 'DealMachine/dealmachine-cli', workflowRunId: process.env.GITHUB_RUN_ID ?? null, verified: false }); + writeFileSync(resolve(output, 'release.json'), JSON.stringify(record, null, 2) + '\n'); + // Check both existing packages before writing either one; a mixed pair must stop here. + for (const artifact of record.artifacts) { + if (registries[artifact.name].versions?.[plan.version]) verifyPublishedPackage(artifact, registries[artifact.name], plan, { requireTag: true }); + } + if (mode === 'publish') assertCurrentMaster(); + for (const artifact of record.artifacts) { + if (registries[artifact.name].versions?.[plan.version]) { + console.log(`Verified existing ${artifact.name}@${plan.version}; resuming without republishing it.`); + } else { + execute('npm', ['publish', resolve(output, artifact.filename), '--ignore-scripts', '--access', 'public', '--registry', 'https://registry.npmjs.org', '--tag', plan.tag, ...(mode === 'dry-run' ? ['--dry-run'] : [])]); + } + if (mode === 'publish') { + // Public registry replicas may briefly lag the successful publish response. + let metadata; + for (let attempt = 0; attempt < 6; attempt++) { + metadata = await registry(artifact.name); + if (metadata.versions?.[plan.version] && metadata['dist-tags']?.[plan.tag] === plan.version) break; + if (attempt < 5) await setTimeout(2_000); + } + verifyPublishedPackage(artifact, metadata, plan, { requireTag: true }); + } + } + if (mode === 'publish') { + for (const artifact of record.artifacts) verifyPublishedPackage(artifact, await registry(artifact.name), plan, { requireTag: true }); + record.verified = true; + record.verifiedAt = new Date().toISOString(); + writeFileSync(resolve(output, 'release.json'), JSON.stringify(record, null, 2) + '\n'); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `version=${plan.version}\n`); + if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Published and verified both CLI packages at **${plan.version}** (latest).\n\nSource: \`${sourceSha}\`\n\n- [@dealmachine/cli](https://www.npmjs.com/package/@dealmachine/cli/v/${plan.version})\n- [dealmachine](https://www.npmjs.com/package/dealmachine/v/${plan.version})\n\nThe release artifact includes both package integrity digests.\n`); + } +} finally { + for (const [name, contents] of originals) writeFileSync(resolve(root, name), contents); +} diff --git a/scripts/release-plan.mjs b/scripts/release-plan.mjs new file mode 100644 index 0000000..9f4cb8b --- /dev/null +++ b/scripts/release-plan.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; + +export const releasePackages = ['@dealmachine/cli', 'dealmachine']; +const stable = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const compare = (left, right) => { + const a = left.split('.').map(Number); + const b = right.split('.').map(Number); + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +}; + +// Registry metadata makes a retry recover the version already published for this commit. +export function planRelease(baseVersion, sourceSha, registries) { + assert(stable.test(baseVersion), 'Automatic production releases require a stable committed version.'); + assert(/^[a-f0-9]{40}$/.test(sourceSha), 'A full Git source SHA is required.'); + const versions = releasePackages.flatMap(name => Object.entries(registries[name].versions ?? {})); + const matching = [...new Set(versions.filter(([, value]) => value.dealmachineRelease?.sourceSha === sourceSha).map(([version]) => version))]; + assert(matching.length <= 1, 'This source has multiple published versions. An owner must reconcile the registry.'); + const stableVersions = versions.map(([version]) => version).filter(version => stable.test(version)).sort(compare); + const highest = stableVersions.at(-1); + let version = matching[0]; + if (version) { + assert(stable.test(version), 'This source was published as a prerelease; production needs a new reviewed commit.'); + assert(!highest || compare(version, highest) >= 0, 'A newer version already exists. Refusing to move latest backward.'); + for (const name of releasePackages) { + const existing = registries[name].versions?.[version]; + assert(!existing || existing.dealmachineRelease?.sourceSha === sourceSha, `${name}@${version} belongs to another source. Refusing a mixed release.`); + } + } else if (!highest || compare(baseVersion, highest) > 0) { + version = baseVersion; + } else { + const [major, minor, patch] = highest.split('.').map(Number); + version = `${major}.${minor}.${patch + 1}`; + } + return { version, tag: 'latest', sourceSha }; +} + +export function verifyPublishedPackage(artifact, metadata, plan, { requireTag = false } = {}) { + const published = metadata.versions?.[plan.version]; + assert(published, `${artifact.name}@${plan.version} is missing from npm.`); + assert.equal(published.dealmachineRelease?.sourceSha, plan.sourceSha, `${artifact.name} source does not match this release.`); + assert.equal(published.dist?.integrity, artifact.integrity, `${artifact.name} published bytes differ. Never replace an existing version.`); + if (artifact.name === 'dealmachine') { + assert.equal(published.dependencies?.['@dealmachine/cli'], plan.version, 'The alias must pin this exact implementation version.'); + } + if (requireTag) assert.equal(metadata['dist-tags']?.[plan.tag], plan.version, `${artifact.name} latest is not this version. An owner must reconcile the channel.`); + return published; +} diff --git a/src/version.ts b/src/version.ts index 9978a3a..17ea6be 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const CLI_VERSION = '0.4.0-rc.4'; +export const CLI_VERSION = '0.4.0'; export const CLI_USER_AGENT = `dm-cli/${CLI_VERSION}`; diff --git a/tests/release.test.ts b/tests/release.test.ts new file mode 100644 index 0000000..6a3cf5a --- /dev/null +++ b/tests/release.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { planRelease, verifyPublishedPackage } from '../scripts/release-plan.mjs'; + +const sourceSha = 'a'.repeat(40); +const otherSha = 'b'.repeat(40); +const published = (sha = otherSha, integrity = 'sha512-reviewed') => ({ + dealmachineRelease: { sourceSha: sha }, + dist: { integrity }, + dependencies: { '@dealmachine/cli': '0.4.1' }, +}); +const registry = (versions = {}, latest = '0.3.0') => ({ versions, 'dist-tags': { latest } }); +const registries = (implementation = registry(), alias = registry()) => ({ '@dealmachine/cli': implementation, dealmachine: alias }); + +describe('automatic CLI release contract', () => { + it.each([ + ['0.4.0', ['0.3.0', '0.4.0-rc.4'], '0.4.0'], + ['0.4.0', ['0.4.1', '0.4.10', '0.4.9'], '0.4.11'], + ['1.0.0', ['0.4.12'], '1.0.0'], + ])('selects a stable version from baseline %s and existing %j', (base, existing, expected) => { + const snapshot = registry(Object.fromEntries(existing.map(version => [version, published()]))); + expect(planRelease(base, sourceSha, registries(snapshot)).version).toBe(expected); + }); + + it('resumes the implementation version when alias publication failed', () => { + const snapshot = registry({ '0.4.1': published(sourceSha) }, '0.4.1'); + const plan = planRelease('0.4.0', sourceSha, registries(snapshot)); + expect(plan).toEqual({ version: '0.4.1', sourceSha, tag: 'latest' }); + expect(() => verifyPublishedPackage({ name: '@dealmachine/cli', integrity: 'sha512-reviewed' }, snapshot, plan, { requireTag: true })).not.toThrow(); + expect(() => verifyPublishedPackage({ name: 'dealmachine', integrity: 'sha512-reviewed' }, registry(), plan, { requireTag: true })).toThrow(/missing from npm/); + }); + + it('rejects an alias for another source before completing a partial release', () => { + expect(() => planRelease('0.4.0', sourceSha, registries( + registry({ '0.4.1': published(sourceSha) }), + registry({ '0.4.1': published(otherSha) }), + ))).toThrow(/mixed release/); + }); + + it('refuses a retry that would roll back a newer published version', () => { + expect(() => planRelease('0.4.0', sourceSha, registries(registry({ + '0.4.1': published(sourceSha), '0.4.2': published(otherSha), + })))).toThrow(/move latest backward/); + }); + + it.each([ + ['changed bytes', { ...published(sourceSha), dist: { integrity: 'sha512-different' } }, '0.4.1', /published bytes differ/], + ['wrong alias dependency', { ...published(sourceSha), dependencies: { '@dealmachine/cli': '^0.4.1' } }, '0.4.1', /exact implementation/], + ['unmoved channel', published(sourceSha), '0.3.0', /latest is not this version/], + ['wrong source', published(otherSha), '0.4.1', /source does not match/], + ])('does not report a successful release with %s', (_, metadata, latest, error) => { + expect(() => verifyPublishedPackage( + { name: 'dealmachine', integrity: 'sha512-reviewed' }, + registry({ '0.4.1': metadata }, latest), + { version: '0.4.1', tag: 'latest', sourceSha }, + { requireTag: true }, + )).toThrow(error); + }); +}); From 0850340b61f05fd766eda8445ec3d20053e86f1d Mon Sep 17 00:00:00 2001 From: Dave Oster Date: Thu, 1 Oct 2026 14:41:26 -0500 Subject: [PATCH 2/4] Report production builds and release outcomes to Factory --- .github/scripts/factory-production-intake.mjs | 109 ++++++++++++++++++ .../workflows/factory-production-intake.yml | 37 ++++++ 2 files changed, 146 insertions(+) create mode 100644 .github/scripts/factory-production-intake.mjs create mode 100644 .github/workflows/factory-production-intake.yml diff --git a/.github/scripts/factory-production-intake.mjs b/.github/scripts/factory-production-intake.mjs new file mode 100644 index 0000000..71f5958 --- /dev/null +++ b/.github/scripts/factory-production-intake.mjs @@ -0,0 +1,109 @@ +// Generated from Factory production intake. SHA256: e0a02d6fea6ce2bcfd4782b139c805cb1b737e39a3f92ea1ee56035edd7c1294 +/** Runs in the owning repository with read-only GitHub access. No cloud or npm credentials. */ +export async function collectProduction(repository, token, workflow, jobName, runId) { + const get = async (path) => { + const response = await fetch(`https://api.github.com/repos/${repository}/${path}`, { redirect: 'error', signal: AbortSignal.timeout(20000), + headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } }); + if (!response.ok) + throw new Error(`GitHub production read failed (${response.status}).`); + return response.json(); + }; + const ownedRun = (r) => r && r.head_branch === 'master' && ['push', 'workflow_dispatch'].includes(r.event) + && r.path === `.github/workflows/${workflow}` && r.head_repository?.full_name === repository; + const { workflow_runs: history } = await get(`actions/workflows/${workflow}/runs?branch=master&per_page=30`); + const run = runId ? await get(`actions/runs/${runId}`) : history.find(ownedRun); + if (!ownedRun(run)) + return null; + const jobs = async (r) => (await get(`actions/runs/${r.id}/attempts/${r.run_attempt}/jobs?per_page=100`)); + const deploymentJobs = (r, result) => result.jobs.filter((j) => j.name === jobName && j.head_sha === r.head_sha); + const current = await jobs(run); + if (current.total_count > 100) + throw new Error('Production workflow has more jobs than the collector supports.'); + const matching = deploymentJobs(run, current); + if (matching.length > 1) + throw new Error('Production job name must be unique.'); + const job = matching[0]; + const state = run.status !== 'completed' ? 'running' : run.conclusion !== 'success' ? 'failed' + : job?.status === 'completed' && job.conclusion === 'success' ? 'deployed' : 'skipped'; + const pullRequests = []; + let linkageComplete = false; + // Only an earlier successful deployment establishes the range of newly shipped commits. + // The first tracked release retains its build receipt but requires a person to review its ticket scope. + if (state === 'deployed') { + let previous; + // A rerun finishes at a different time than its original creation. Compare the latest + // completed receipts so a later rerun cannot become an earlier deployment boundary. + const earlier = history.filter((r) => ownedRun(r) && r.id !== run.id && r.updated_at < run.updated_at && r.status === 'completed' && r.conclusion === 'success') + .sort((a, b) => b.updated_at.localeCompare(a.updated_at)).slice(0, 10); + for (const candidate of earlier) { + const prior = await jobs(candidate); + const matches = deploymentJobs(candidate, prior); + if (prior.total_count <= 100 && matches.length === 1 && matches[0].status === 'completed' && matches[0].conclusion === 'success') { + previous = candidate; + break; + } + } + if (previous) { + const diff = await get(`compare/${previous.head_sha}...${run.head_sha}?per_page=100`); + if (['ahead', 'identical'].includes(diff.status) && diff.total_commits <= 100) { + linkageComplete = true; + const found = new Map(); + for (const commit of diff.commits) { + const prs = await get(`commits/${commit.sha}/pulls?per_page=100`); + if (prs.length >= 100) { + linkageComplete = false; + break; + } + for (const pr of prs) + if (pr.merged_at && ['staging', 'master'].includes(pr.base?.ref) && pr.base?.repo?.full_name === repository && pr.merge_commit_sha && diff.commits.some((c) => c.sha === pr.merge_commit_sha)) + found.set(pr.number, pr); + } + if (found.size > 50) + linkageComplete = false; + if (linkageComplete) + for (const pr of found.values()) { + const rows = await get(`pulls/${pr.number}/files?per_page=100`); + if (rows.length >= 100) + linkageComplete = false; + pullRequests.push({ number: pr.number, title: pr.title.slice(0, 300), body: (pr.body ?? '').slice(0, 20000), headRef: pr.head.ref, + mergeSha: pr.merge_commit_sha, files: rows.length < 100 ? rows.map((f) => f.filename) : null }); + } + } + } + } + return { repository, workflow, runId: String(run.id), attempt: run.run_attempt, candidate: run.head_sha, state, + url: `https://github.com/${repository}/actions/runs/${run.id}`, createdAt: run.created_at, updatedAt: run.updated_at, + observedAt: new Date().toISOString(), job: job ? { name: job.name, status: job.status, conclusion: job.conclusion } : null, linkageComplete, pullRequests }; +} + +// Appended to the compiled production collector by scripts/sync-production-intake.mjs. +const endpoint = 'https://factory-rover-81024286635.us-west1.run.app/github/production-intake'; +try { + let runIds = [process.env.FACTORY_RUN_ID].filter(Boolean); + if (!runIds.length) { + const history = await fetch(`https://api.github.com/repos/${process.env.GITHUB_REPOSITORY}/actions/workflows/${process.env.FACTORY_PRODUCTION_WORKFLOW}/runs?branch=master&per_page=30`, { + redirect: 'error', signal: AbortSignal.timeout(20000), headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, Accept: 'application/vnd.github+json' } }); + if (!history.ok) throw new Error(`GitHub production history failed (${history.status}).`); + runIds = (await history.json()).workflow_runs.filter(r => ['push', 'workflow_dispatch'].includes(r.event) && Date.now() - Date.parse(r.created_at) < 7 * 86400000) + .map(r => String(r.id)).reverse(); + } + let failed = false; + for (const runId of runIds) try { + const payload = await collectProduction(process.env.GITHUB_REPOSITORY, process.env.GITHUB_TOKEN, process.env.FACTORY_PRODUCTION_WORKFLOW, process.env.FACTORY_PRODUCTION_JOB, runId); + if (!payload) continue; + const request = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL); + request.searchParams.set('audience', 'https://factory.dealmachine.com/production-intake'); + const identity = await fetch(request, { headers: { Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}` }, signal: AbortSignal.timeout(20000) }); + if (!identity.ok) throw new Error(`GitHub identity request failed (${identity.status}).`); + const { value } = await identity.json(); + if (!value) throw new Error('GitHub returned no intake identity.'); + let body = JSON.stringify(payload); + if (Buffer.byteLength(body) > 500000) { + payload.pullRequests = []; payload.linkageComplete = false; body = JSON.stringify(payload); + } + const response = await fetch(endpoint, { method: 'POST', headers: { Authorization: `Bearer ${value}`, 'Content-Type': 'application/json' }, body, signal: AbortSignal.timeout(60000) }); + if (!response.ok) throw new Error(`Factory production intake failed (${response.status}); scheduled reconciliation will retry.`); + console.log(`Recorded production run ${payload.runId}, attempt ${payload.attempt}: ${payload.state}.`); + } catch (error) { failed = true; console.error(`Production run ${runId}: ${error.message}`); } + if (failed) process.exitCode = 1; +} catch (error) { console.error(error.message); process.exitCode = 1; } diff --git a/.github/workflows/factory-production-intake.yml b/.github/workflows/factory-production-intake.yml new file mode 100644 index 0000000..311c05b --- /dev/null +++ b/.github/workflows/factory-production-intake.yml @@ -0,0 +1,37 @@ +name: Track production builds in Factory +on: + workflow_run: + workflows: ["Publish CLI to npm"] + types: [requested, in_progress, completed] + branches: [master] + schedule: + - cron: '3,13,23,33,43,53 * * * *' + workflow_dispatch: +permissions: + contents: read + pull-requests: read + actions: read + id-token: write +concurrency: + group: factory-production-intake + cancel-in-progress: false +jobs: + intake: + if: github.ref == 'refs/heads/master' && vars.FACTORY_PRODUCTION_INTAKE_ENABLED == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + ref: master + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Record production build and deployment evidence + env: + GITHUB_TOKEN: ${{ github.token }} + FACTORY_PRODUCTION_WORKFLOW: "publish-npm.yml" + FACTORY_PRODUCTION_JOB: "Publish production CLI packages" + FACTORY_RUN_ID: ${{ github.event.workflow_run.id || '' }} + run: node .github/scripts/factory-production-intake.mjs From 6cfb9ed209d526057d8dff81947faf8ecd33a3e3 Mon Sep 17 00:00:00 2001 From: Dave Oster Date: Thu, 1 Oct 2026 14:52:49 -0500 Subject: [PATCH 3/4] Bind manual deployments to the reviewed source commit --- .github/workflows/publish-npm.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 1fdf843..9a5a438 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -3,6 +3,11 @@ on: push: branches: [master] workflow_dispatch: + inputs: + expected_sha: + description: Full reviewed source commit; reject a dispatch if the branch moved + type: string + required: false permissions: contents: read concurrency: @@ -24,6 +29,15 @@ jobs: - uses: actions/checkout@v4 with: persist-credentials: false + - name: Verify the reviewed dispatch candidate + if: github.event_name == 'workflow_dispatch' && inputs.expected_sha != '' + env: + EXPECTED_SHA: ${{ inputs.expected_sha }} + shell: bash + run: | + [[ "$EXPECTED_SHA" =~ ^[a-f0-9]{40}$ ]] + test "$GITHUB_SHA" = "$EXPECTED_SHA" + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - uses: actions/setup-node@v4 with: node-version: '24' From 32862e5416505ca95fa4e4d4da1e9797a65752a4 Mon Sep 17 00:00:00 2001 From: Dave Oster Date: Thu, 1 Oct 2026 15:19:25 -0500 Subject: [PATCH 4/4] Keep automatic npm publication disabled until commissioning --- .github/workflows/publish-npm.yml | 2 +- AGENTS.md | 2 +- docs/development.md | 2 +- docs/releases.md | 6 ++++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 9a5a438..1aca15a 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -16,7 +16,7 @@ concurrency: jobs: publish: name: Publish production CLI packages - if: github.ref == 'refs/heads/master' + if: github.ref == 'refs/heads/master' && vars.PRODUCTION_DEPLOY_ENABLED == 'true' runs-on: ubuntu-latest timeout-minutes: 30 environment: diff --git a/AGENTS.md b/AGENTS.md index 2fb7054..d980ce5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,6 @@ Use the existing behavioral tests for changed Commands. Run `npm run check` and Set versions with `npm run release:version -- ` so the canonical package, alias and lockfile agree. A release requires an explicit channel and approved release scope. Publishing the CLI does not deploy the API, MCP server, docs site or Next app. -Merging into `master` runs automatic production npm publication of both packages once npm trusted publishing is configured. The committed stable version is a release floor; the workflow selects the next available patch and records the source SHA in both packages. Treat a master merge as a release handoff. See the release guide for retry, integrity and setup requirements. +Production npm publication is disabled until the release owner explicitly enables the GitHub `PRODUCTION_DEPLOY_ENABLED` variable after configuring npm trusted publishing. While disabled, master pushes and manual dispatches skip the publishing job. After commissioning, merging into `master` automatically publishes both packages. The committed stable version is a release floor; the workflow selects the next available patch and records the source SHA in both packages. Treat a master merge as a release handoff once publication is enabled. Source delivery alone never authorizes changing the gate or npm publisher settings. See the release guide for retry, integrity and setup requirements. Call CLI capabilities Commands, API capabilities Endpoints, and distributable agent instructions Playbooks. Write concrete copy and do not add em dashes to docs or comments. diff --git a/docs/development.md b/docs/development.md index 2f38fff..02bc7c6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,4 +19,4 @@ The public agent plugin manifests and `skills/dealmachine` are retained in this From Factory, use `npm run setup:cli`, `npm run dev:cli`, `npm run cli:check` and `npm run cli:test:package`. Factory's `where cli` locates this checkout and `scripts cli` discovers its npm scripts. Read [releases](releases.md) before publishing. -Every `master` push runs the production npm workflow. `npm run release:auto:dry-run` reads the public registry, chooses the version, validates both package archives and simulates publication without changing the registry. It restores the local version files after the run. Published packages record their exact source SHA; the automatic version does not create a source commit or tag. +Every `master` push runs the production npm workflow; its publishing job stays skipped until `PRODUCTION_DEPLOY_ENABLED` is explicitly set to `true` during authorized commissioning. `npm run release:auto:dry-run` reads the public registry, chooses the version, validates both package archives and simulates publication without changing the registry. It restores the local version files after the run. Published packages record their exact source SHA; the automatic version does not create a source commit or tag. diff --git a/docs/releases.md b/docs/releases.md index af30ada..770fa95 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -4,7 +4,7 @@ ## Automatic production publication -Every push to this public repository's `master` runs `publish-npm.yml`, named **Publish CLI to npm**. The **Publish production CLI packages** job builds, checks, installs and publishes both packages to `latest`, then verifies their source SHA, integrity and distribution tags through the public registry. Factory observes that job for production build and release tracking. A failed or incomplete pair is not a successful deployment. Source merges, publication and any subsequent production regression are separate evidence. +Every push to this public repository's `master` runs `publish-npm.yml`, named **Publish CLI to npm**. Its **Publish production CLI packages** job requires the GitHub Actions variable `PRODUCTION_DEPLOY_ENABLED` to equal `true`; an unset or false gate skips publication for both master pushes and manual dispatches. After authorized commissioning, the job builds, checks, installs and publishes both packages to `latest`, then verifies their source SHA, integrity and distribution tags through the public registry. Factory observes that job for production build and release tracking. A skipped job or failed or incomplete pair is not a successful deployment. Source merges, publication and any subsequent production regression are separate evidence. The committed stable version, initially `0.4.0`, sets the minimum release version. When that version or a later stable version already exists in either package, the workflow selects the next patch after the highest stable version. For example, a committed `0.4.0` with published `0.4.10` produces `0.4.11`. Set and commit a higher minor or major version with `release:version` when the change warrants it. Automatic publishing refuses a prerelease baseline. @@ -28,9 +28,11 @@ For **both** npm package settings, configure a GitHub trusted publisher with the Create the GitHub `npm` environment and restrict deployment branches to `master`. For unattended publication on a reviewed master merge, this environment must not require a second manual review. Repository branch review and checks remain the source approval gate. Do not weaken an existing environment rule without the release owner's approval. +Keep `PRODUCTION_DEPLOY_ENABLED` unset or false while landing source changes. After both trusted publishers and the environment are verified, the release owner must explicitly authorize setting this repository variable to `true`. That commissioning action enables future master pushes and manual recovery dispatches to publish. Neither merging this workflow nor setting up source delivery authorizes enabling the gate. Existing owner release commands retain their explicit release-authorization requirements. + The job uses GitHub-hosted Ubuntu, Node 24, npm 11.5.1 or later, and `id-token: write`. No npm token belongs in GitHub secrets or chat. Trusted publication from this public repository supplies npm provenance. npm package-owner access is needed for the publisher setup, which cannot be verified from public package metadata. See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). -After setup and merge, recover the current `master` release with: +After authorized setup, gate activation and merge, recover the current `master` release with: ```sh gh workflow run publish-npm.yml --repo DealMachine/dealmachine-cli --ref master