From cd4fb3a31bda4ffe15af6d32e09d5d97d7651e51 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Fri, 31 Jul 2026 16:31:44 -0500 Subject: [PATCH] Prepare first npm prerelease Separate repository readiness from the irreversible tag that publishes the first public alpha. Pin registry and provenance metadata, verify the exact tagged main state without credentials, and require environment approval before the one-time bootstrap token can publish under next. --- .github/workflows/publish.yml | 106 ++++++++++++++++++++++++++++++++++ README.md | 23 ++++++-- RELEASING.md | 104 +++++++++++++++++++++++++++++++++ SECURITY.md | 7 ++- package-lock.json | 4 +- package.json | 7 ++- test/package.test.js | 106 ++++++++++++++++++++++++++++++++++ 7 files changed, 346 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 RELEASING.md 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",