diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..40d087f --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,106 @@ +name: Publish + +on: + push: + tags: ["v*"] + +permissions: {} + +concurrency: + group: npm-publish + cancel-in-progress: false + +jobs: + verify: + name: Verify release + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.0 + package-manager-cache: false + - name: Verify tag and source commit + run: | + # release-source-checks:begin + set -euo pipefail + test "$GITHUB_REPOSITORY" = "firstdraft/cli" + test "$GITHUB_EVENT_NAME" = "push" + test "$GITHUB_REF_TYPE" = "tag" + test "$GITHUB_REF" = "refs/tags/$GITHUB_REF_NAME" + test "$GITHUB_REF_PROTECTED" = "true" + release_sha="$(git rev-parse 'HEAD^{commit}')" + event_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" + test "$release_sha" = "$event_sha" + git fetch --force --no-tags origin \ + "+refs/heads/main:refs/remotes/origin/main" \ + "+refs/tags/${GITHUB_REF_NAME}:refs/release-check/tag" + remote_tag_sha="$(git rev-parse 'refs/release-check/tag^{commit}')" + test "$release_sha" = "$remote_tag_sha" + package_version="$(node --print 'JSON.parse(require("node:fs").readFileSync("package.json", "utf8")).version')" + test "$GITHUB_REF_NAME" = "v$package_version" + git rev-list --first-parent refs/remotes/origin/main > "$RUNNER_TEMP/main-first-parent" + grep -Fqx "$release_sha" "$RUNNER_TEMP/main-first-parent" + # release-source-checks:end + - run: npm ci --ignore-scripts + - run: npm audit + - run: npm run check + - run: npm pack --dry-run --json --ignore-scripts + + publish: + name: Publish to npm + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: npm + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.0 + package-manager-cache: false + registry-url: https://registry.npmjs.org/ + - name: Verify approved release + env: + NPM_RELEASE_ENABLED: ${{ vars.NPM_RELEASE_ENABLED }} + # Inline by design: no tagged repository script runs while the bootstrap secret is in scope. + # test/package.test.js enforces synchronization with verify. + run: | + # release-source-checks:begin + set -euo pipefail + test "$NPM_RELEASE_ENABLED" = "true" + test "$GITHUB_REPOSITORY" = "firstdraft/cli" + test "$GITHUB_EVENT_NAME" = "push" + test "$GITHUB_REF_TYPE" = "tag" + test "$GITHUB_REF" = "refs/tags/$GITHUB_REF_NAME" + test "$GITHUB_REF_PROTECTED" = "true" + release_sha="$(git rev-parse 'HEAD^{commit}')" + event_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" + test "$release_sha" = "$event_sha" + git fetch --force --no-tags origin \ + "+refs/heads/main:refs/remotes/origin/main" \ + "+refs/tags/${GITHUB_REF_NAME}:refs/release-check/tag" + remote_tag_sha="$(git rev-parse 'refs/release-check/tag^{commit}')" + test "$release_sha" = "$remote_tag_sha" + package_version="$(node --print 'JSON.parse(require("node:fs").readFileSync("package.json", "utf8")).version')" + test "$GITHUB_REF_NAME" = "v$package_version" + git rev-list --first-parent refs/remotes/origin/main > "$RUNNER_TEMP/main-first-parent" + grep -Fqx "$release_sha" "$RUNNER_TEMP/main-first-parent" + # release-source-checks:end + - name: Publish verified source + run: npm publish --access public --tag next --provenance --ignore-scripts + # Remove this credential after the first publish enables trusted publishing. + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index 47fdc1d..ce0c66c 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,29 @@ `firstdraft` is the command-line client for [First Draft](https://github.com/firstdraft/firstdraft). It is being built for agents that author and review Foundation Plans with their users. -The package is not released yet. This repository contains the auditable command shell, local Foundation Plan -initialization, subject identity generation, conditional whole-document push, and whole-graph analysis status -polling, plus explicit compilation and verified local artifact materialization; release behavior will arrive in -reviewed increments. +Public alpha releases use npm's `next` tag. This release line contains the auditable command shell, local Foundation +Plan initialization, subject identity generation, conditional whole-document push, whole-graph analysis status +polling, explicit compilation, and verified local artifact materialization. Interfaces may change between +prereleases, and publishing the CLI does not make the wider First Draft service generally available. ## Requirements - Running the CLI: Node.js 22.0.0 or newer - Working on this repository: Node.js 24.18.0 (pinned in `.tool-versions`) +## Installation + +Once npm reports a public alpha, install the current prerelease explicitly: + +```sh +npm install --global firstdraft@next +firstdraft --version +``` + +There is intentionally no stable `latest` release yet. Pin an exact prerelease version instead of `next` when a +repeatable installation matters. Remote Plan push, status, and compilation commands require a compatible First +Draft service and are currently intended for coordinated trials. + ## Development ```sh @@ -161,7 +174,7 @@ unexpected programming defects. explicitly invoked API command. - Package contents are allowlisted and checked before release. - CI exercises the exact minimum Node.js version separately from current development tooling. -- Public releases will use npm provenance after the first useful version bootstraps trusted publishing. +- Public packages carry npm provenance linking their registry bytes to the reviewed GitHub workflow and commit. Security issues should follow the [private reporting instructions](https://github.com/firstdraft/cli/security/advisories/new). diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..8350160 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,104 @@ +# Releasing First Draft CLI + +Publishing is a separate, explicit action after a release-preparation pull request has merged. npm registry bytes +and package versions cannot be replaced, so do not create or push a release tag as a dry run. + +## Repository and registry setup + +Before the first release, a repository administrator must: + +1. Confirm `firstdraft/cli` is public. The release workflow deliberately removes checkout credentials and re-fetches + the public release refs anonymously. +2. Protect `main` with pull-request and CI requirements, and add a `v*` tag ruleset that restricts tag creation, + update, and deletion. +3. Create a GitHub environment named `npm`, restrict it to release tags, require a reviewer, prevent self-review, + and add the environment variable `NPM_RELEASE_ENABLED=true`. The workflow fails before publishing when this + variable is absent. +4. Confirm that the bootstrap publisher account has write-protecting 2FA enabled. The first publish creates this + unscoped package under that account; organization access cannot be granted before the package exists. +5. Create a one-day granular npm token with read/write access to All Packages, no organization-management access, + and bypass 2FA enabled. A new unscoped package cannot yet be selected individually. Add it directly as the `npm` + environment secret `NPM_TOKEN`; never put it in an Issue, chat, workflow file, repository file, or command + history. + +The token is a one-time bootstrap credential. After the package exists, use the repository-pinned Node.js 24.18.0 +toolchain with npm 11.16.0 to give the npm organization durable read/write access and configure trusted publishing: + +```sh +npm --version +npm access grant read-write firstdraft.com:developers firstdraft +``` + +```sh +npm trust github firstdraft \ + --repository firstdraft/cli \ + --file publish.yml \ + --environment npm \ + --allow-publish +npm trust list firstdraft +``` + +Confirm the listed relationship identifies `firstdraft/cli`, `publish.yml`, the `npm` environment, and publish +permission. Before creating another release tag, merge a follow-up pull request that removes the `NODE_AUTH_TOKEN` +environment from the publish step. Then remove the GitHub secret, revoke the bootstrap token, and configure the +package to disallow token publication. The workflow continues through GitHub OIDC without a persistent npm +credential. + +## Prepare a release + +1. Update `package.json` and `package-lock.json` to the exact release version. +2. Keep prereleases on the `next` dist-tag. Do not create `latest` until a stable release is intentionally approved. +3. Update user-facing documentation and release notes for behavior changes. +4. Run: + + ```sh + npm ci --ignore-scripts + npm audit + npm run check + ``` + +5. Merge the reviewed pull request only after local and hosted checks pass. + +## Publish + +The manual boundary is creation of the version tag. From an up-to-date, clean `main`, verify the intended commit and +then create and push `v`. For version `0.1.0-alpha.1`, the tag is `v0.1.0-alpha.1`. +Push one release tag at a time; the workflow serializes publication, but GitHub retains at most one pending run in a +concurrency group. + +The workflow rejects accidental or stale inputs unless they use a protected `v*` tag in `firstdraft/cli`, the tag +equals `v` plus the version in `package.json`, the remote tag still identifies the triggering commit, and that commit +appears in the first-parent history of `origin/main`. First-parent membership allows an older reviewed `main` state +after another change lands while rejecting intermediate commits from a merged side branch. The workflow reruns the +complete check, waits for approval in the `npm` environment, reverifies the remote refs, and publishes to the public +registry with provenance under `next`. + +The tag ruleset and `npm` environment approval are the external trust boundary because a tag-push run loads its +workflow from the tagged commit. Before approving the `npm` deployment, the reviewer must confirm: + +- The tag, package version, and commit SHA are the intended release. +- The commit is a known reviewed state in protected `main` history and its required checks passed. +- `.github/workflows/publish.yml` at that commit is the reviewed workflow, still selects the `npm` environment, and + publishes only under `next` with provenance. +- The unprivileged verification job passed for that exact commit. + +Do not move or reuse a release tag. If the tagged commit is not a first-parent state of `main`, merge the intended +change and prepare a new version rather than moving an already shared tag. + +## Verify and recover + +After publication, inspect the registry before retrying any reported failure; the package may already exist. Verify +the exact version, `next` dist-tag, integrity metadata, and provenance metadata: + +```sh +npm view firstdraft@0.1.0-alpha.1 \ + version dist.integrity dist.shasum repository.url engines bin --json +npm dist-tag ls firstdraft +``` + +Install `firstdraft@0.1.0-alpha.1` into a fresh temporary prefix, confirm `firstdraft --version`, compare the packed +file list with the release workflow, and run `npm audit signatures` after an exact installation. + +A published version cannot be overwritten or reused. For a bad release, move `next` to a known-good version, +deprecate the bad version, and publish a corrected higher version. Treat unpublishing as an exceptional incident +response, not a routine rollback. diff --git a/SECURITY.md b/SECURITY.md index 7c42fb2..b21b9f6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,5 +4,8 @@ Please report suspected vulnerabilities through a [private GitHub security advisory](https://github.com/firstdraft/cli/security/advisories/new). Do not include sensitive details in a public Issue. -The CLI has not released a supported version yet. This policy will name supported release lines before the first -public package is published. +## Supported versions + +Before the first public npm publication, there is no supported release. During alpha, only the prerelease currently +identified by npm's `next` tag receives security fixes. Earlier prereleases and unreleased source snapshots are not +supported release lines. The alpha interface may change without backward compatibility between versions. diff --git a/package-lock.json b/package-lock.json index 78b658a..6b8e629 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "firstdraft", - "version": "0.0.0", + "version": "0.1.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "firstdraft", - "version": "0.0.0", + "version": "0.1.0-alpha.1", "license": "MIT", "bin": { "firstdraft": "bin/firstdraft.js" diff --git a/package.json b/package.json index 3b1139d..88ebfa7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firstdraft", - "version": "0.0.0", + "version": "0.1.0-alpha.1", "description": "Command-line interface for First Draft", "license": "MIT", "type": "module", @@ -23,7 +23,10 @@ }, "homepage": "https://github.com/firstdraft/cli#readme", "publishConfig": { - "access": "public" + "access": "public", + "provenance": true, + "registry": "https://registry.npmjs.org/", + "tag": "next" }, "scripts": { "check": "npm run typecheck && npm run lint && npm run format:check && npm test && npm run pack:check && npm run pack:smoke", diff --git a/test/package.test.js b/test/package.test.js index aae81dd..1141fe4 100644 --- a/test/package.test.js +++ b/test/package.test.js @@ -5,6 +5,43 @@ import test from "node:test"; const metadata = JSON.parse( await readFile(new URL("../package.json", import.meta.url), "utf8"), ); +const publishWorkflow = await readFile( + new URL("../.github/workflows/publish.yml", import.meta.url), + "utf8", +); + +/** + * @param {string} jobSource + * @returns {string[]} + */ +function releaseSourceChecks(jobSource) { + const beginMarker = "# release-source-checks:begin"; + const endMarker = "# release-source-checks:end"; + const beginIndex = jobSource.indexOf(beginMarker); + + assert.ok(beginIndex >= 0, "release check begin marker must exist"); + assert.equal( + jobSource.indexOf(beginMarker, beginIndex + beginMarker.length), + -1, + "release check begin marker must be unique", + ); + + const checksStart = beginIndex + beginMarker.length; + const endIndex = jobSource.indexOf(endMarker, checksStart); + + assert.ok(endIndex >= checksStart, "release check end marker must exist"); + assert.equal( + jobSource.indexOf(endMarker, endIndex + endMarker.length), + -1, + "release check end marker must be unique", + ); + + return jobSource + .slice(checksStart, endIndex) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} test("package metadata preserves the audited runtime boundary", () => { assert.equal(metadata.name, "firstdraft"); @@ -26,6 +63,75 @@ test("package metadata preserves the audited runtime boundary", () => { } }); +test("package metadata preserves the public prerelease boundary", () => { + assert.deepEqual(metadata.repository, { + type: "git", + url: "git+https://github.com/firstdraft/cli.git", + }); + assert.deepEqual(metadata.publishConfig, { + access: "public", + provenance: true, + registry: "https://registry.npmjs.org/", + tag: "next", + }); +}); + +test("privileged publication repeats every release source check", () => { + const verifyJobStart = publishWorkflow.indexOf("\n verify:\n"); + const publishJobStart = publishWorkflow.indexOf("\n publish:\n"); + + assert.ok(verifyJobStart >= 0, "verify job must exist"); + assert.ok(publishJobStart > verifyJobStart, "publish job must follow verify"); + + const verifyJob = publishWorkflow.slice(verifyJobStart, publishJobStart); + const publishJob = publishWorkflow.slice(publishJobStart); + const npmApprovalGate = 'test "$NPM_RELEASE_ENABLED" = "true"'; + const verifyChecks = releaseSourceChecks(verifyJob); + const publishChecks = releaseSourceChecks(publishJob); + const npmApprovalGates = publishChecks.filter( + (line) => line === npmApprovalGate, + ); + const publishCommand = "npm publish"; + const publishCommandIndex = publishJob.indexOf(publishCommand); + const publishChecksEndIndex = publishJob.indexOf( + "# release-source-checks:end", + ); + + assert.ok(verifyChecks.length > 0, "release source checks must not be empty"); + assert.equal( + verifyChecks[0], + "set -euo pipefail", + "release checks must fail closed", + ); + assert.equal( + npmApprovalGates.length, + 1, + "publish must require explicit approval once", + ); + assert.equal( + publishChecks.indexOf(npmApprovalGate), + 1, + "approval must immediately follow shell safeguards", + ); + assert.ok(publishCommandIndex >= 0, "publish command must exist"); + assert.equal( + publishJob.indexOf( + publishCommand, + publishCommandIndex + publishCommand.length, + ), + -1, + "publish command must be unique", + ); + assert.ok( + publishChecksEndIndex < publishCommandIndex, + "release checks must precede publication", + ); + assert.deepEqual( + publishChecks.filter((line) => line !== npmApprovalGate), + verifyChecks, + ); +}); + test("package metadata defines no installation lifecycle", () => { for (const script of [ "preinstall",