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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ jobs:
with:
node-version: 24.18.0
package-manager-cache: false
- name: Verify release toolchain
run: |
test "$(node --version)" = "v24.18.0"
test "$(npm --version)" = "11.16.0"
- run: npm ci --ignore-scripts
- run: npm audit
- run: npm run check
11 changes: 6 additions & 5 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ jobs:
run: |
# release-source-checks:begin
set -euo pipefail
test "$(node --version)" = "v24.18.0"
test "$(npm --version)" = "11.16.0"
test "$GITHUB_REPOSITORY" = "firstdraft/cli"
test "$GITHUB_EVENT_NAME" = "push"
test "$GITHUB_REF_TYPE" = "tag"
Expand Down Expand Up @@ -75,12 +77,14 @@ jobs:
- 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.
# Inline by design: the approved job reruns the reviewed checks before OIDC publication.
# test/package.test.js enforces synchronization with verify.
run: |
# release-source-checks:begin
set -euo pipefail
test "$NPM_RELEASE_ENABLED" = "true"
test "$(node --version)" = "v24.18.0"
test "$(npm --version)" = "11.16.0"
test "$GITHUB_REPOSITORY" = "firstdraft/cli"
test "$GITHUB_EVENT_NAME" = "push"
test "$GITHUB_REF_TYPE" = "tag"
Expand All @@ -99,8 +103,5 @@ jobs:
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
- name: Publish verified source with OIDC
run: npm publish --access public --tag next --provenance --ignore-scripts
# Remove after v0.1.0 and before any later release tag.
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ identifies `0.1.0-alpha.2`. After `0.1.0` is published under `next`, an untagged
that historical alpha rather than `0.1.0` until the separate promotion occurs. Remote Plan and Compilation commands
require a compatible First Draft service and are currently intended for coordinated trials.

Before the first ordinary `v0.1.0` release tag is created, an operator must inspect npm's exact listed
[GitHub Actions trusted-publisher](https://docs.npmjs.com/trusted-publishers/) relationship for
`@firstdraft.com/cli`, type `github`, the `firstdraft/cli` repository, `publish.yml`, the protected `npm` environment,
and permission `createPackage` (npm's trust-list vocabulary for the allowed publish operation). The release workflow
authenticates only with a short-lived GitHub OIDC credential. A persistent npm token, `NODE_AUTH_TOKEN`, or GitHub
Actions secret is not permitted as a publication fallback.

## Authenticate API commands

Create an API token in First Draft and provide it only through the environment when running a network command:
Expand Down
58 changes: 36 additions & 22 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
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.

Only a version that npm reports as published or a protected release tag that exists is immutable. Before either
exists, an unpublished candidate may be revised without changing its proposed version; its exact source SHA and
reviewed digests identify it. Reconcile the registry and remote tags before deciding that a version was consumed.

## Pre-1.0 version and channel policy

Before `1.0.0`, First Draft CLI uses ordinary `0.MINOR.PATCH` versions. Increase `MINOR` for a breaking
Expand Down Expand Up @@ -66,11 +70,9 @@ The published scoped package already exists. Before another release, a repositor
npm view '@firstdraft.com/cli' name repository.url versions dist-tags --json
```

5. If trusted publishing has not yet been verified, `v0.1.0` is the final release permitted to use the bootstrap
credential. Create a one-day granular npm token with read/write access limited to the existing
`@firstdraft.com/cli` package, no organization-management access, and bypass 2FA enabled. Add it directly as the
`npm` environment secret `NPM_TOKEN`; never put it in an Issue, chat, workflow file, repository file, or command
history.
5. Before creating the first ordinary `v0.1.0` tag, verify npm trusted publishing for the exact package, repository,
workflow, protected environment, and allowed publish operation. No persistent npm credential is permitted for
this or a later release workflow.

Use the repository-pinned Node.js 24.18.0 toolchain with npm 11.16.0 to verify the organization's durable read/write
access. Grant it only if the package did not inherit access for the `developers` team:
Expand All @@ -82,7 +84,7 @@ npm access grant read-write firstdraft.com:developers '@firstdraft.com/cli'
```

Using an interactive npm login backed by the account's 2FA, configure trusted publishing for the exact package,
repository, workflow, and protected environment. Do not use the bypass-2FA bootstrap token for trust setup:
repository, workflow, protected environment, and allowed publish action:

```sh
npm trust github '@firstdraft.com/cli' \
Expand All @@ -93,18 +95,18 @@ npm trust github '@firstdraft.com/cli' \
npm trust list '@firstdraft.com/cli'
```

Confirm the listed relationship identifies `firstdraft/cli`, `publish.yml`, the `npm` environment, and publish
permission. After publishing `v0.1.0` and before creating any later 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:
Confirm `npm trust list` reports type `github`, repository `firstdraft/cli`, file `publish.yml`, environment `npm`,
and permission `createPackage`, which is npm's trust-list vocabulary for the allowed publish operation, before
creating `v0.1.0`. npm does not validate the saved relationship by attempting an exchange, so each case-sensitive
value must be inspected. The publish job must remain on a GitHub-hosted runner with `id-token: write` and must not
read `NODE_AUTH_TOKEN`, an npm token, or any GitHub Actions secret. Confirm the repository and `npm` environment
secret lists contain no npm automation secret. Trusted publishing's short-lived OIDC exchange is the sole workflow
publication credential; an authentication failure stops the release and must never fall back to a persistent token.

```sh
npm access set mfa=publish '@firstdraft.com/cli'
```

Confirm that the package's npm Publishing access now requires 2FA and disallows tokens. The workflow continues
through GitHub OIDC without a persistent npm credential. Apply this restriction only after the trusted publisher
has been verified.
As optional defense-in-depth after trusted publication is operationally proven, an npm administrator may complete
the separate security-key ceremony and set package **Publishing access** to **Require two-factor authentication and
disallow tokens**. This package-level setting is not a `v0.1.0` release prerequisite and must not be reported as
enabled until it is directly observed.

## Historical alpha publications

Expand All @@ -126,17 +128,21 @@ approved promotion.
3. Apply the pre-1.0 policy: use a minor increment for a breaking compatibility line and a patch increment for a
change that is otherwise backward-compatible. Keep the initial distribution under `next` independently of that
version choice. Do not move `latest` during release publication.
4. Confirm neither the exact package version nor its `v<package-version>` tag already exists.
5. Update user-facing documentation and release notes for behavior changes.
6. Run:
4. Confirm neither the exact package version nor its `v<package-version>` tag already exists. If both remain absent,
the unpublished candidate may retain its proposed version while its exact SHA and digests are revised.
5. Re-run `npm trust list '@firstdraft.com/cli'`, verify the exact `github`/repository/file/environment/`createPackage`
relationship described above, and confirm the workflow contains no persistent npm credential or GitHub Actions
secret.
6. Update user-facing documentation and release notes for behavior changes.
7. Run:

```sh
npm ci --ignore-scripts
npm audit
npm run check
```

7. Merge the reviewed pull request only after local and hosted checks pass.
8. Merge the reviewed pull request only after local and hosted checks pass.

## Publish

Expand All @@ -150,7 +156,8 @@ equals `v` plus the version in `package.json`, the remote tag still identifies t
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`.
registry with provenance under `next`. It authenticates only through the exact npm trusted-publisher relationship and
the job's short-lived GitHub OIDC token; it reads no persistent npm credential or GitHub Actions secret.

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:
Expand All @@ -159,6 +166,8 @@ workflow from the tagged commit. Before approving the `npm` deployment, the revi
- 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 the public `@firstdraft.com/cli` package only under `next` with provenance.
- npm lists the exact `github`/`firstdraft/cli`/`publish.yml`/`npm`/`createPackage` trusted-publisher relationship,
and the GitHub repository and `npm` environment contain no npm automation secret.
- 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
Expand All @@ -178,6 +187,11 @@ npm dist-tag ls '@firstdraft.com/cli'
Install `@firstdraft.com/cli@0.1.0` 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.

If OIDC authentication fails, reconcile both the registry version and protected remote tag first. If only npm's
listed relationship is wrong, correct it on npm and rerun the existing workflow's failed jobs without changing the
tagged source. If the workflow filename, environment, permissions, or other identity at the tagged commit is wrong,
the protected tag is immutable: prepare the next version rather than moving the tag. Never add a token fallback.

A published version cannot be overwritten or reused. For a bad release, move `next` only to a known-good compatible
version if one exists; otherwise deprecate the bad version and publish a corrected higher version. Treat
unpublishing as an exceptional incident response, not a routine rollback.
Expand Down
130 changes: 120 additions & 10 deletions test/package.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,15 @@ test("ordinary pre-1.0 versions use the approval-gated distribution channel", ()
});
});

test("privileged publication repeats every release source check", () => {
test("OIDC 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 verifyJob = workflowJob(publishWorkflow, "verify");
const publishJob = workflowJob(publishWorkflow, "publish");
const npmApprovalGate = 'test "$NPM_RELEASE_ENABLED" = "true"';
const verifyChecks = releaseSourceChecks(verifyJob);
const publishChecks = releaseSourceChecks(publishJob);
Expand All @@ -101,6 +101,16 @@ test("privileged publication repeats every release source check", () => {
"npm publish --access public --tag next --provenance --ignore-scripts";
const publishCommandIndex = publishJob.indexOf(publishCommand);
const publishInvocation = "npm publish";
const oidcPermission = "\n id-token: write\n";
const oidcPermissionIndex = publishJob.indexOf(oidcPermission);
const approvalEnvironmentKey = "\n environment:";
const approvalEnvironment = "\n environment: npm\n";
const approvalEnvironmentIndex = publishJob.indexOf(approvalEnvironment);
const runnerKey = "\n runs-on:";
const approvedRunner = "\n runs-on: ubuntu-latest\n";
const approvedRunnerIndex = publishJob.indexOf(approvedRunner);
const nodeVersion = "\n node-version: 24.18.0\n";
const npmVersionCheck = 'test "$(npm --version)" = "11.16.0"';
const publishChecksEndIndex = publishJob.indexOf(
"# release-source-checks:end",
);
Expand Down Expand Up @@ -140,13 +150,86 @@ test("privileged publication repeats every release source check", () => {
false,
"publication must not mutate a dist-tag separately",
);
if (metadata.version !== "0.1.0") {
assert.equal(
publishWorkflow.includes("NODE_AUTH_TOKEN"),
false,
"only v0.1.0 may use the bootstrap publication credential",
);
}
assert.ok(oidcPermissionIndex >= 0, "publication must permit OIDC tokens");
assert.equal(
publishJob.indexOf(
oidcPermission,
oidcPermissionIndex + oidcPermission.length,
),
-1,
"the OIDC permission must be unique",
);
assert.equal(
verifyJob.includes(oidcPermission),
false,
"only publication may request an OIDC token",
);
assert.ok(
approvalEnvironmentIndex >= 0,
"publication must select the approval-gated npm environment",
);
assert.equal(
publishJob.indexOf(approvalEnvironmentKey),
approvalEnvironmentIndex,
"publication must use the exact approval-gated environment",
);
assert.equal(
publishJob.indexOf(
approvalEnvironmentKey,
approvalEnvironmentIndex + approvalEnvironmentKey.length,
),
-1,
"the approval-gated environment must be unique",
);
assert.equal(
verifyJob.includes(approvalEnvironmentKey),
false,
"verification must not enter the npm environment",
);
assert.ok(
approvedRunnerIndex >= 0,
"trusted publication must use the approved GitHub-hosted runner",
);
assert.equal(
publishJob.indexOf(runnerKey),
approvedRunnerIndex,
"the publish job must use only the approved runner syntax",
);
assert.equal(
publishJob.indexOf(runnerKey, approvedRunnerIndex + runnerKey.length),
-1,
"the publish job must declare one runner",
);
assert.equal(
publishJob.includes(nodeVersion),
true,
"trusted publication must use the pinned Node version",
);
assert.equal(
publishChecks.includes(npmVersionCheck),
true,
"trusted publication must verify the pinned npm version",
);
assert.equal(
publishWorkflow.includes("NODE_AUTH_TOKEN"),
false,
"trusted publication must not use a persistent npm credential",
);
assert.equal(
publishWorkflow.includes("NPM_TOKEN"),
false,
"trusted publication must not name a persistent npm token",
);
assert.doesNotMatch(
publishWorkflow,
/\bsecrets\b/i,
"trusted publication must not read a GitHub Actions secret",
);
assert.doesNotMatch(
publishWorkflow,
/auth[_-]?token/i,
"trusted publication must not configure an authentication token",
);
assert.ok(
publishChecksEndIndex < publishCommandIndex,
"release checks must precede publication",
Expand All @@ -157,6 +240,33 @@ test("privileged publication repeats every release source check", () => {
);
});

/**
* @param {string} workflowSource
* @param {string} jobName
* @returns {string}
*/
function workflowJob(workflowSource, jobName) {
const marker = `\n ${jobName}:\n`;
const startIndex = workflowSource.indexOf(marker);

assert.ok(startIndex >= 0, `${jobName} job must exist`);
assert.equal(
workflowSource.indexOf(marker, startIndex + marker.length),
-1,
`${jobName} job must be unique`,
);

const contentStart = startIndex + marker.length;
const followingJob = /\n {2}[a-zA-Z0-9_-]+:\n/.exec(
workflowSource.slice(contentStart),
);
const endIndex = followingJob
? contentStart + followingJob.index
: workflowSource.length;

return workflowSource.slice(startIndex, endIndex);
}

test("package metadata defines no installation lifecycle", () => {
for (const script of [
"preinstall",
Expand Down