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
109 changes: 109 additions & 0 deletions .github/scripts/factory-production-intake.mjs
Original file line number Diff line number Diff line change
@@ -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; }
37 changes: 37 additions & 0 deletions .github/workflows/factory-production-intake.yml
Original file line number Diff line number Diff line change
@@ -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
54 changes: 35 additions & 19 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
@@ -1,42 +1,58 @@
name: Publish CLI to npm
on:
push:
branches: [master]
workflow_dispatch:
inputs:
version:
description: Exact version already committed on master
required: true
expected_sha:
description: Full reviewed source commit; reject a dispatch if the branch moved
type: string
channel:
description: npm distribution tag
required: true
default: next
type: choice
options: [next, latest]
required: false
permissions:
contents: read
concurrency:
group: cli-npm-release
cancel-in-progress: false
jobs:
publish:
if: github.ref == 'refs/heads/master'
name: Publish production CLI packages
if: github.ref == 'refs/heads/master' && vars.PRODUCTION_DEPLOY_ENABLED == 'true'
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
- 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'
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ Use the existing behavioral tests for changed Commands. Run `npm run check` and

Set versions with `npm run release:version -- <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.

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.
2 changes: 2 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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; 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.
Loading
Loading