diff --git a/.changeset/README.md b/.changeset/README.md index b5466272..042af030 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -43,10 +43,14 @@ code change it describes, until it is later applied. ## What this repository does *not* use changesets for -- **No npm publishing.** Every workspace package is `"private": true`; - none of them are ever published to the npm registry. `access: - "restricted"` in `config.json` is a harmless default that is never - exercised — this repo never runs `changeset publish`. +- **npm publishing is a dispatched step, not `changeset publish`.** Only + `@openspec-ui/cli` is public; every other workspace package is + `"private": true` and never reaches the registry. A version bump of the + CLI reaches npm only when somebody dispatches + `.github/workflows/publish.yml` with `cli_version` (ADR-0048). This repo + never runs `changeset publish`, so `access: "restricted"` in + `config.json` is never exercised; the workflow passes `--access public` + itself. - **The actual release mechanism is unchanged.** CI (`release-extension` job in `.github/workflows/quality.yml`) tags the commit and publishes a GitHub Release with the built `.vsix` once `package.json`'s version has @@ -54,8 +58,8 @@ code change it describes, until it is later applied. changelog entry that trigger that existing pipeline; it does not replace it. Publishing that release to the VS Code Marketplace is a step somebody dispatches by hand, for a version they name - (`.github/workflows/publish-marketplace.yml`): a version bump reaches - the Marketplace only when somebody asks it to. + (`.github/workflows/publish.yml`): a version bump reaches the + Marketplace only when somebody asks it to. - **`openspec-ui`** (the private workspace root, always `0.0.0`) is listed in `config.json`'s `ignore` array — it is a workspace container, never a release artifact, and was never bumped by hand diff --git a/.github/workflows/publish-marketplace.yml b/.github/workflows/publish-marketplace.yml deleted file mode 100644 index f736d5a5..00000000 --- a/.github/workflows/publish-marketplace.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: Publish to the Marketplace - -# Asked for, never inferred (the-marketplace-publish-is-a-manual-step). -# -# What the automatic path does, in `quality.yml`: a push to `main` whose -# `openspec-ui-vscode` version has no tag yet is tagged -# `openspec-ui-vscode@` and published as a GitHub Release with -# the VSIX attached. That is the whole of it. Nothing there reaches the -# Visual Studio Marketplace. -# -# This workflow is the step that does, and it runs on no event: a person -# dispatches it, names the version and types the confirmation. Not every -# release is meant for the Marketplace, a publish reaches every installed -# copy through the editor's auto-update, and a version pulled from the -# Marketplace stays gone and its number can never be reused. -# -# What is published is the artifact that was released - the VSIX the -# extension integration suite exercised, downloaded from that tag's -# release - and never a rebuild of whatever `main` holds now. -# -# `scripts/check-publish-workflow.mjs` fails the lint gate if this file -# stops holding to any of that. - -on: - workflow_dispatch: - inputs: - version: - description: 'The version to publish, as packages/extension/package.json states it (for example 0.62.0)' - required: true - type: string - confirm: - description: 'Type publish to confirm. Not every release is meant for the Marketplace.' - required: true - type: string - -permissions: - contents: read - -concurrency: - group: publish-marketplace - cancel-in-progress: false - -env: - NODE_VERSION: 22.11.0 - NPM_VERSION: 10.9.0 - -jobs: - publish: - name: Publish the released VSIX - runs-on: ubuntu-latest - timeout-minutes: 15 - # No protection rules are required for this to run. It is declared so - # a required reviewer can be added, or the secret scoped here, without - # editing this file - and so what was published, and when, appears - # outside the run log. - environment: marketplace - steps: - - name: Refuse a run that was not confirmed - env: - CONFIRM: ${{ inputs.confirm }} - VERSION: ${{ inputs.version }} - run: | - if [ "${CONFIRM}" != "publish" ]; then - echo "Refusing: the confirmation field reads '${CONFIRM}', and it has to read exactly 'publish'." - echo "Nothing was published, and the token was not read." - exit 1 - fi - echo "Confirmed. Publishing openspec-ui-vscode ${VERSION}." - - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: npm - - - run: npm install --global npm@${NPM_VERSION} - - run: npm ci - - - name: Take the VSIX from the release that version was tagged as - env: - GH_TOKEN: ${{ github.token }} - VERSION: ${{ inputs.version }} - run: | - TAG="openspec-ui-vscode@${VERSION}" - if ! gh release view "${TAG}" > /dev/null 2>&1; then - echo "Refusing: ${TAG} has no GitHub Release. Only a released version can be published." - exit 1 - fi - mkdir -p publish - gh release download "${TAG}" --pattern '*.vsix' --dir publish - VSIX=$(ls publish/*.vsix 2>/dev/null | head -1) - if [ -z "${VSIX}" ]; then - echo "Refusing: the release for ${TAG} carries no .vsix asset." - exit 1 - fi - case "${VSIX}" in - *"${VERSION}.vsix") ;; - *) - echo "Refusing: ${VSIX} is not the file version ${VERSION} would have produced." - exit 1 - ;; - esac - echo "VSIX=${VSIX}" >> "${GITHUB_ENV}" - echo "Publishing ${VSIX}, as it was released." - - - name: Publish it - env: - VSCE_PAT: ${{ secrets.VSCE_PAT }} - working-directory: packages/extension - run: npx vsce publish --packagePath "${GITHUB_WORKSPACE}/${VSIX}" - - - name: Say what went out - env: - VERSION: ${{ inputs.version }} - run: | - echo "openspec-ui-vscode ${VERSION} is on the Marketplace." >> "${GITHUB_STEP_SUMMARY}" - echo "" >> "${GITHUB_STEP_SUMMARY}" - echo "Published from the release for openspec-ui-vscode@${VERSION}, dispatched by ${GITHUB_ACTOR}." >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..e43920d7 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,225 @@ +name: Publish + +# Asked for, never inferred (the-marketplace-publish-is-a-manual-step, +# one-dispatch-publishes-to-both-channels). +# +# What the automatic path does, in `quality.yml`: a push to `main` whose +# `openspec-ui-vscode` version has no tag yet is tagged +# `openspec-ui-vscode@` and published as a GitHub Release with +# the VSIX attached. That is the whole of it. Nothing there reaches the +# Visual Studio Marketplace or the npm registry. +# +# This workflow is the step that does, and it runs on no event: a person +# dispatches it, names what to publish and types the confirmation. Not +# every release is meant for a registry, a Marketplace publish reaches +# every installed copy through the editor's auto-update, and a version +# pulled from either registry stays gone and its number can never be +# reused. +# +# Two channels, two versions, because the two products are versioned +# independently (changesets): `vscode_version` names an +# `openspec-ui-vscode` release for the Marketplace, `cli_version` names an +# `@openspec-ui/cli` version for npm. A field left empty skips its +# channel, so what is published is only what was asked for. +# +# The Marketplace publishes the artifact that was released - the VSIX the +# extension integration suite exercised, downloaded from that tag's +# release - and never a rebuild of whatever `main` holds now. +# +# npm has no release to download: the CLI is not tagged or released. It is +# built here, from the commit that is `main`, and published only if the +# version it carries is the version that was named. Authentication is npm +# Trusted Publishing (OIDC): the registry trusts this workflow file in +# this repository, so there is no npm token anywhere. The trusted +# publisher on npmjs.com names this file and the `npm` environment; if +# this file is renamed, that setting has to follow. +# +# `scripts/check-publish-workflow.mjs` fails the lint gate if this file +# stops holding to any of that. + +on: + workflow_dispatch: + inputs: + vscode_version: + description: 'Publish to the Marketplace: the openspec-ui-vscode version, as packages/extension/package.json states it (for example 0.62.0). Empty skips the Marketplace.' + required: false + type: string + cli_version: + description: 'Publish to npm: the @openspec-ui/cli version, as packages/cli/package.json states it (for example 0.30.0). Empty skips npm.' + required: false + type: string + confirm: + description: 'Type publish to confirm. Not every release is meant for a registry.' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: publish + cancel-in-progress: false + +env: + NODE_VERSION: 22.11.0 + # The repository's pinned npm builds and tests everything, here as in + # quality.yml. Trusted Publishing needs a newer one, which the npm job + # installs for the publish step alone. + NPM_VERSION: 10.9.0 + NPM_PUBLISH_VERSION: 11.5.1 + +jobs: + confirm: + name: Confirm the publish + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # Both publishing jobs need this one, so neither can read its + # credential before the confirmation has been checked. + - name: Refuse a run that was not confirmed + env: + CONFIRM: ${{ inputs.confirm }} + VSCODE_VERSION: ${{ inputs.vscode_version }} + CLI_VERSION: ${{ inputs.cli_version }} + run: | + if [ "${CONFIRM}" != "publish" ]; then + echo "Refusing: the confirmation field reads '${CONFIRM}', and it has to read exactly 'publish'." + echo "Nothing was published, and no credential was read." + exit 1 + fi + if [ -z "${VSCODE_VERSION}" ] && [ -z "${CLI_VERSION}" ]; then + echo "Refusing: no version was named. Name vscode_version, cli_version or both." + exit 1 + fi + echo "Confirmed. Marketplace: ${VSCODE_VERSION:-skipped}. npm: ${CLI_VERSION:-skipped}." + + marketplace: + name: Publish to the Marketplace + needs: confirm + if: inputs.vscode_version != '' + runs-on: ubuntu-latest + timeout-minutes: 15 + # No protection rules are required for this to run. It is declared so + # a required reviewer can be added, or the secret scoped here, without + # editing this file - and so what was published, and when, appears + # outside the run log. + environment: marketplace + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - run: npm install --global npm@${NPM_VERSION} + - run: npm ci + + - name: Take the VSIX from the release that version was tagged as + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.vscode_version }} + run: | + TAG="openspec-ui-vscode@${VERSION}" + if ! gh release view "${TAG}" > /dev/null 2>&1; then + echo "Refusing: ${TAG} has no GitHub Release. Only a released version can be published." + exit 1 + fi + mkdir -p publish + gh release download "${TAG}" --pattern '*.vsix' --dir publish + VSIX=$(ls publish/*.vsix 2>/dev/null | head -1) + if [ -z "${VSIX}" ]; then + echo "Refusing: the release for ${TAG} carries no .vsix asset." + exit 1 + fi + case "${VSIX}" in + *"${VERSION}.vsix") ;; + *) + echo "Refusing: ${VSIX} is not the file version ${VERSION} would have produced." + exit 1 + ;; + esac + echo "VSIX=${VSIX}" >> "${GITHUB_ENV}" + echo "Publishing ${VSIX}, as it was released." + + - name: Publish it + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + working-directory: packages/extension + run: npx vsce publish --packagePath "${GITHUB_WORKSPACE}/${VSIX}" + + - name: Say what went out + env: + VERSION: ${{ inputs.vscode_version }} + run: | + echo "openspec-ui-vscode ${VERSION} is on the Marketplace." >> "${GITHUB_STEP_SUMMARY}" + echo "" >> "${GITHUB_STEP_SUMMARY}" + echo "Published from the release for openspec-ui-vscode@${VERSION}, dispatched by ${GITHUB_ACTOR}." >> "${GITHUB_STEP_SUMMARY}" + + npm: + name: Publish to npm + needs: confirm + if: inputs.cli_version != '' + runs-on: ubuntu-latest + timeout-minutes: 15 + # The environment the trusted publisher on npmjs.com names. + environment: npm + permissions: + contents: read + # The OIDC token the registry exchanges for a publish credential. + # Held by this job alone. + id-token: write + steps: + # What goes out is the commit that is `main`, never a branch a + # dispatch happened to be started from. + - name: Refuse a dispatch that was not started from main + run: | + if [ "${GITHUB_REF}" != "refs/heads/main" ]; then + echo "Refusing: this was dispatched from ${GITHUB_REF}, and npm is published from refs/heads/main only." + exit 1 + fi + + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + registry-url: https://registry.npmjs.org + + - run: npm install --global npm@${NPM_VERSION} + - run: npm ci + + - name: Refuse a version that is not the one this commit carries, or that is already out + env: + VERSION: ${{ inputs.cli_version }} + run: | + HAVE=$(node -p "require('./packages/cli/package.json').version") + if [ "${HAVE}" != "${VERSION}" ]; then + echo "Refusing: packages/cli/package.json states ${HAVE} on this commit, and ${VERSION} was named." + exit 1 + fi + if npm view "@openspec-ui/cli@${VERSION}" version > /dev/null 2>&1; then + echo "Refusing: @openspec-ui/cli@${VERSION} is already on npm, and a version can never be published twice." + exit 1 + fi + + - name: Build the bundle that is published + run: npm run build --workspace @openspec-ui/cli + + # Trusted Publishing needs a newer npm than the one the repository + # is pinned to. It is installed here, after everything that reads + # the lockfile has run, and by an exact version. + - run: npm install --global npm@${NPM_PUBLISH_VERSION} + + - name: Publish it + working-directory: packages/cli + run: npm publish --access public + + - name: Say what went out + env: + VERSION: ${{ inputs.cli_version }} + run: | + echo "@openspec-ui/cli ${VERSION} is on npm." >> "${GITHUB_STEP_SUMMARY}" + echo "" >> "${GITHUB_STEP_SUMMARY}" + echo "Built from ${GITHUB_SHA}, dispatched by ${GITHUB_ACTOR}." >> "${GITHUB_STEP_SUMMARY}" diff --git a/README.md b/README.md index e3ad6ab5..3bd82473 100644 --- a/README.md +++ b/README.md @@ -423,14 +423,22 @@ no matching git tag yet, CI (`release-extension` job in download a specific build; the artifact itself is never committed into `packages/` or anywhere else in git. -Reaching the Visual Studio Marketplace is a separate step, and a manual -one: not every release is meant for it. Somebody dispatches the -`Publish to the Marketplace` workflow -(`.github/workflows/publish-marketplace.yml`), names the version and types -the confirmation, and that run publishes the `.vsix` from that version's -GitHub Release — the artifact the extension integration suite exercised, -never a rebuild. No push, tag or merge publishes anything. The token is a -repository secret read by that one job; +Reaching the Visual Studio Marketplace or the npm registry is a separate +step, and a manual one: not every release is meant for either. Somebody +dispatches the `Publish` workflow (`.github/workflows/publish.yml`), names +`vscode_version`, `cli_version` or both, and types the confirmation; a +version left empty skips its channel. + +- The Marketplace publishes the `.vsix` from that version's GitHub Release + — the artifact the extension integration suite exercised, never a + rebuild. Its token is a repository secret read by that one job. +- npm publishes `@openspec-ui/cli`, built from `main` and only if + `packages/cli/package.json` states the version that was named. It uses + npm Trusted Publishing, so there is no npm token at all; the trusted + publisher on npmjs.com names `publish.yml` and the `npm` environment + (ADR-0048). + +No push, tag or merge publishes anything. `scripts/check-publish-workflow.mjs` fails the lint gate if any of that stops holding. diff --git a/docs/adr/0009-publish-cli-to-npm.md b/docs/adr/0009-publish-cli-to-npm.md index efa018d0..3656af6a 100644 --- a/docs/adr/0009-publish-cli-to-npm.md +++ b/docs/adr/0009-publish-cli-to-npm.md @@ -1,7 +1,8 @@ # 0009: Publish `@openspec-ui/cli` to npm as a Bundled Package Status: Accepted (decision 4 superseded by -[ADR-0020](0020-cli-runs-a-change.md)) +[ADR-0020](0020-cli-runs-a-change.md); decision 5 completed by +[ADR-0048](0048-npm-is-published-by-trusted-publishing-from-the-dispatch.md)) Date: 2026-08-26 diff --git a/docs/adr/0048-npm-is-published-by-trusted-publishing-from-the-dispatch.md b/docs/adr/0048-npm-is-published-by-trusted-publishing-from-the-dispatch.md new file mode 100644 index 00000000..f1dcdcb3 --- /dev/null +++ b/docs/adr/0048-npm-is-published-by-trusted-publishing-from-the-dispatch.md @@ -0,0 +1,91 @@ +# 0048: npm Is Published by Trusted Publishing, From the Same Dispatch as the Marketplace + +Status: Accepted; completes [ADR-0009](0009-publish-cli-to-npm.md) decision 5 + +Date: 2026-10-10 + +## Context + +ADR-0009 made `@openspec-ui/cli` publishable and left the publish itself +as "a manual step for the user (or a future authenticated CI release job)", +rejecting an automated release job because no registry credentials +existed to test one. The package has since been published by hand, twice. + +npm now requires two-factor authentication to publish. A person with a +phone can still publish by hand; a CI job cannot answer a one-time +password. npm offers two ways round that: a granular access token with +two-factor bypass, which is a long-lived secret, or Trusted Publishing, +where the registry trusts one workflow file in one repository and +exchanges that workflow's short-lived OIDC token for a publish +credential. The Marketplace publish +(`the-marketplace-publish-is-a-manual-step`) already settled the shape +of such a step in this repository: asked for, never inferred, confirmed +by a typed word, the credential read by one job. + +Two things differ between the channels. The extension is tagged and +released on every version bump, so the Marketplace publishes the +released artifact. The CLI is neither tagged nor released, so npm has no +artifact to take; and Trusted Publishing can only publish from the +workflow run itself. + +## Decision + +1. **npm is published by Trusted Publishing.** The trusted publisher on + npmjs.com names `.github/workflows/publish.yml` and the `npm` + environment. No npm token exists, in a secret or anywhere else, and + the lint gate fails if a workflow names one. +2. **The Marketplace and npm publishes share one dispatch, + `publish.yml`.** It takes `vscode_version`, `cli_version` and + `confirm`. A version left empty skips that channel, so each product is + published only when named, at its own version. A first job checks the + confirmation, and both publishing jobs need it, so neither reads a + credential unconfirmed. +3. **npm publishes what `main` carries, checked against the version + named.** The job refuses a dispatch from any ref but `main`, refuses a + version that is not the one `packages/cli/package.json` states on that + commit, and refuses a version already on the registry. It builds the + bundle there and publishes it; there is no released artifact to + download, and inventing a tag and a release for the CLI is not part of + this decision. +4. **The pinned npm builds; a newer one publishes.** Trusted Publishing + needs npm 11.5.1 or later. The repository stays on its pinned npm + (`10.9.0`, the release-quality requirement) for install and build, and + the npm job installs the exact newer version after both, for the + publish step alone. + +## Rejected Alternatives + +### A granular access token with two-factor bypass + +Rejected: a long-lived secret that expires and has to be re-issued, and +the one credential in this arrangement that can be copied out of a +run's environment. Trusted Publishing has nothing to copy. + +### Two workflows, one per channel, as the Marketplace publish started + +Rejected at the owner's choice, in favour of one run: publishing both is one act, +and a second dispatch is a second chance to forget the confirmation or +mistype a version. The cost is one file naming both channels, which the +lint gate keeps honest. + +### Raise the pinned npm for the whole repository + +Rejected: it would touch every job in three workflow files, the +`packageManager` and `volta` pins and possibly the lockfile, for one +step's benefit. The pin exists so that what CI checks is reproducible. + +### Tag and release the CLI, and publish the released artifact + +Rejected for now: it is the symmetric design, but it adds a release path +to `quality.yml` for a package nobody downloads from a release page. It +can be added later without changing this decision's guarantees. + +## Consequences + +- `.github/workflows/publish-marketplace.yml` is replaced by + `publish.yml`. Anything that dispatched the old file by name uses the + new one. The npmjs.com trusted publisher must name `publish.yml`; + renaming the file breaks npm publishing until that setting follows. +- `scripts/check-publish-workflow.mjs` guards both channels. +- ADR-0009 decision 5 is completed: the publish is automated as far as a + person's dispatch, and no further. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3b2925b2..41689a51 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -54,5 +54,6 @@ Alternatives / Consequences. | [0045](0045-every-action-is-a-verb-and-a-noun.md) | Every action is a verb and a noun | Accepted | | [0046](0046-every-message-has-an-identifier.md) | Every message has an identifier | Accepted | | [0047](0047-what-waits-for-a-person-is-a-dialog.md) | What waits for a person is a dialog | Accepted | +| [0048](0048-npm-is-published-by-trusted-publishing-from-the-dispatch.md) | npm is published by Trusted Publishing, from the same dispatch as the Marketplace | Accepted; completes 0009 decision 5 | New architecture-impacting changes must add an ADR and reference it from the related OpenSpec change. diff --git a/openspec/changes/one-dispatch-publishes-to-both-channels/design.md b/openspec/changes/one-dispatch-publishes-to-both-channels/design.md new file mode 100644 index 00000000..0998f62f --- /dev/null +++ b/openspec/changes/one-dispatch-publishes-to-both-channels/design.md @@ -0,0 +1,99 @@ +## Context + +Two published products, versioned independently by changesets: +`openspec-ui-vscode` (tagged and released on every bump, so the +Marketplace publish can take the released VSIX) and `@openspec-ui/cli` +(neither tagged nor released). Trusted Publishing on npm trusts a +repository's workflow file and, as far as the registry documents it, the +top-level workflow of a run rather than a reusable workflow it calls. The +full reasoning is in ADR-0048; this document is the shape of the +workflow. + +## Goals / Non-Goals + +**Goals** + +- One dispatch that can publish to either channel or both, each at the + version it names. +- No npm token, anywhere. +- The guarantees of the Marketplace publish unchanged: dispatch only, + typed confirmation before any credential is read, the released artifact. +- The lint gate holds for both channels. + +**Non-Goals** + +- Tagging or releasing the CLI. npm publishes from `main` against a named + version instead. +- Raising the repository's pinned npm for install and build. +- Changing `quality.yml`, either copy. +- Publishing the private workspace packages. +- Dispatching the workflow. The first real publish is the owner's. + +## Decisions + +### One file, `publish.yml`, three jobs + +`confirm` checks the word and that a version was named; `marketplace` and +`npm` each need it and are skipped when their version is empty. They do +not need each other: a failure in one does not cancel the other, and +neither can be taken back, so the run summary of each says what went out. + +Rejected: two workflow files, as before, which doubles the confirmation +and the version typing; and reusable workflows called from a thin +dispatcher, because Trusted Publishing is bound to the file that is +registered and a reusable workflow is the case the registry is least +clear about. + +Renaming `publish-marketplace.yml` to `publish.yml` and registering the +new name on npmjs.com is preferred to putting the Marketplace job in a +file called `publish-npm.yml`. + +### npm builds and publishes from `main`, for the version named + +There is no released CLI artifact to download. The job therefore checks +out the dispatch's commit, refuses any ref but `main`, refuses a version +that `packages/cli/package.json` does not state there, and refuses one +already on the registry (`npm view`), then builds the bundle and +publishes. Trusted Publishing signs provenance for what was built in this +run, which is the claim a registry consumer can check. + +Rejected: tagging and releasing the CLI first. It is the symmetric +design, and it can be added later without changing these guarantees; it +costs a release path in `quality.yml` for a package nobody downloads from +a release page. + +### The pinned npm builds, a newer one publishes + +`npm@10.9.0` installs and builds, as everywhere else. After the build the +job installs `npm@11.5.1`, the first release that supports Trusted +Publishing, for the publish step alone. Its engines accept the pinned +Node.js 22.11.0. + +Rejected: raising the pin repository-wide, which touches every job and the +lockfile for one step. + +### The lint gate reads the new file + +`check-publish-workflow.mjs` reads `publish.yml` and fails where: a +trigger other than `workflow_dispatch` appears; a confirmation, either +version input or the `needs: confirm` of a publishing job is missing; the +Marketplace publish loses `--packagePath` or rebuilds; the npm job is not +in the `npm` environment, lacks `id-token: write`, does not refuse a +non-`main` ref or does not check the version against the commit and the +registry; `id-token: write` is granted twice; an npm token is named; or +another workflow names a token or publishes to either channel. + +## Risks / Trade-offs + +- **The workflow file name is a contract with npmjs.com.** Renaming + `publish.yml` breaks the npm publish until the trusted publisher + follows. The workflow's own comment and ADR-0048 say so. +- **npm is built from `main`, not from a tag.** Between a version being + merged and a dispatch, `main` can move on; the version check fails then, + rather than publishing a different tree under the same number. +- **A failed npm publish after a successful Marketplace one** leaves the + channels at different versions. Each is independent by design, and a + re-dispatch with only the failed channel's version names the remainder. +- **Not exercised before merge.** A workflow that publishes cannot be run + in a pull request. The lint check and its tests cover the file's shape; + the first dispatch is the owner's and is recorded in the tasks. diff --git a/openspec/changes/one-dispatch-publishes-to-both-channels/proposal.md b/openspec/changes/one-dispatch-publishes-to-both-channels/proposal.md new file mode 100644 index 00000000..86092606 --- /dev/null +++ b/openspec/changes/one-dispatch-publishes-to-both-channels/proposal.md @@ -0,0 +1,65 @@ +## Why + +`@openspec-ui/cli` is meant for npm (ADR-0009), and its publish was left +as a manual step on the grounds that no registry credential existed to +test an automated one. It has since been published by hand, twice. npm now +requires two-factor authentication, which a person can answer and a CI job +cannot, and the owner wants the publish to run from the pipeline, asked +for in the way the Marketplace publish already is +(`the-marketplace-publish-is-a-manual-step`). + +The Marketplace publish is a workflow of its own with one version input. +Adding npm as a second workflow would mean two dispatches, two +confirmations and two version fields for what the owner treats as one act: +releasing. The two products are versioned independently, so one run has to +name two versions and be able to skip either. + +ADR-0048 records the decisions this change implements. + +## What Changes + +- **One dispatch, two channels.** `.github/workflows/publish.yml` replaces + `publish-marketplace.yml`. It takes `vscode_version`, `cli_version` and + `confirm`; an empty version skips its channel; a first job checks the + confirmation and both publishing jobs need it. +- **npm is published by Trusted Publishing.** The `npm` job holds + `id-token: write`, is bound to the `npm` environment and uses no token. + The trusted publisher on npmjs.com names `publish.yml` and that + environment. +- **npm publishes what `main` carries, for the version named.** The job + refuses a dispatch from any other ref, a version that is not the one + `packages/cli/package.json` states, and a version already on the + registry. The pinned npm builds; an exact newer npm (11.5.1) is + installed for the publish step alone. +- **The lint gate guards both channels.** `check-publish-workflow.mjs` + also fails on an npm token anywhere, a second workflow publishing to + npm, and a publishing job that does not wait for the confirmation. +- **The documents say what happens.** `README.md` and `.changeset/ + README.md` stop saying that nothing is published to npm. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `release-quality`: the repository can publish `@openspec-ui/cli` to npm, + from the same dispatch as the Marketplace publish, and only when a + person names the version. + +## Impact + +- **`.github/workflows/publish.yml`**: new; `publish-marketplace.yml` is + removed. Anything that dispatched the old file by name uses the new one. +- **`.github/workflows/quality.yml`** and **`.gitea/workflows/quality.yml`**: + untouched. +- **`scripts/check-publish-workflow.mjs`** and its test: now read + `publish.yml` and check the npm job. +- **`README.md`, `.changeset/README.md`**: corrected. +- **`docs/adr/0048-...md`**, **`docs/adr/README.md`**, **ADR-0009's + status**: new decision recorded. +- **npmjs.com** (outside the repository): the trusted publisher for + `@openspec-ui/cli` must name `publish.yml` and the `npm` environment. +- No package's behaviour changes, so no changeset. diff --git a/openspec/changes/one-dispatch-publishes-to-both-channels/specs/release-quality/spec.md b/openspec/changes/one-dispatch-publishes-to-both-channels/specs/release-quality/spec.md new file mode 100644 index 00000000..5ed7f07c --- /dev/null +++ b/openspec/changes/one-dispatch-publishes-to-both-channels/specs/release-quality/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: Publishing to npm is asked for, never inferred + +The repository SHALL be able to publish a version of `@openspec-ui/cli` to +the npm registry, and SHALL do so only when a person dispatches that +publish and names the version. The same dispatch SHALL be able to publish +`openspec-ui-vscode` to the Marketplace, each channel at the version named +for it, and a channel whose version is not named SHALL be skipped. + +No push, tag, schedule or merge SHALL start an npm publish. The dispatch +SHALL require the typed confirmation described for the Marketplace, and +both publishing jobs SHALL wait for it, so that neither reads a credential +before the confirmation has been checked. + +The publish SHALL be refused unless the dispatch was started from `main`, +the version named is the version `packages/cli/package.json` states on +that commit, and that version is not already on the registry. What is +published SHALL be the bundle built in that run. + +npm SHALL be authenticated by Trusted Publishing: the registry trusts the +publishing workflow and its environment, and no npm token SHALL exist in +a repository secret, in any workflow or in any document. The OIDC +permission the registry exchanges SHALL be granted to the npm job alone. +The repository's pinned npm SHALL install and build; a newer, exactly +named npm MAY be installed for the publish step alone. + +A check SHALL fail the lint gate where the publishing workflow stops +holding to this: another trigger, a missing confirmation, a publishing job +that does not wait for it, an npm job outside its environment, an npm +token, or a second workflow publishing to npm. + +#### Scenario: A CLI version is merged + +- **WHEN** a version of `@openspec-ui/cli` is merged to `main` +- **THEN** nothing reaches npm until a person dispatches the publish for + that version + +#### Scenario: One dispatch names only one channel + +- **WHEN** the publish is dispatched with `cli_version` and no + `vscode_version` +- **THEN** `@openspec-ui/cli` is published and the Marketplace job is + skipped + +#### Scenario: A version that this commit does not carry + +- **WHEN** the publish is dispatched for a `cli_version` other than the + one `packages/cli/package.json` states, or for one already on npm +- **THEN** the run fails naming both versions, and nothing is published + +#### Scenario: A dispatch from another branch + +- **WHEN** the publish is dispatched from a ref other than `main` +- **THEN** the npm job fails before it builds or publishes + +#### Scenario: An npm token or a second path appears + +- **WHEN** a workflow names an npm token, or a workflow other than the + publishing one runs `npm publish` +- **THEN** the lint gate fails diff --git a/openspec/changes/one-dispatch-publishes-to-both-channels/tasks.md b/openspec/changes/one-dispatch-publishes-to-both-channels/tasks.md new file mode 100644 index 00000000..f8f6ec38 --- /dev/null +++ b/openspec/changes/one-dispatch-publishes-to-both-channels/tasks.md @@ -0,0 +1,58 @@ +Asked for by the owner on 2026-10-10: publish `@openspec-ui/cli` to npm +from the pipeline despite two-factor authentication, together with the +Marketplace publish in one run, and only what belongs to each channel. +Decisions are in ADR-0048. + +## 1. The workflow + +- [x] 1.1 A new `.github/workflows/publish.yml` with `on: + workflow_dispatch` and no other trigger, taking `vscode_version`, + `cli_version` (both optional) and `confirm`; `publish-marketplace.yml` + is removed. +- [x] 1.2 A `confirm` job refuses a run unless `confirm` reads exactly + `publish` and at least one version is named; `marketplace` and `npm` + both declare `needs: confirm` and run only for a non-empty version. +- [x] 1.3 The `marketplace` job is the former publishing job: release + lookup, `.vsix` download, `vsce publish --packagePath`, `VSCE_PAT`, + `environment: marketplace`. +- [x] 1.4 The `npm` job is bound to `environment: npm`, holds + `id-token: write` and no token, refuses a ref other than `main`, + refuses a version that `packages/cli/package.json` does not state or + that `npm view` finds, builds with the pinned npm, installs + `npm@11.5.1` and runs `npm publish --access public` in `packages/cli`. + +## 2. The check that keeps it that way + +- [x] 2.1 `scripts/check-publish-workflow.mjs` reads `publish.yml` and + checks both channels as `design.md` lists. +- [x] 2.2 `scripts/check-publish-workflow.test.mjs` breaks each new + guarantee once and asserts the failure by its words. + +## 3. What the documents say + +- [x] 3.1 `docs/adr/0048-...md` recorded, indexed in `docs/adr/README.md`, + and ADR-0009's status points at it. +- [x] 3.2 `README.md` and `.changeset/README.md` describe the one dispatch + and stop saying that nothing is published to npm; neither names a token. + +## 4. Checks + +- [ ] 4.1 `npm run typecheck && npm run lint && npm run test`, run + unpiped. Record each package's count. +- [x] 4.2 No changeset is written: no package's behaviour changes, and + `lint:changesets` passes without one. +- [x] 4.3 `lint:english` after `git add`, `lint:changesets`, + `lint:test-budgets`, `lint:source-text`, `lint:screenshots` and + `lint:publish-workflow` pass. + + Done 2026-10-10: all passed; the publish workflow's 20 script tests pass. +- [x] 4.4 `openspec validate one-dispatch-publishes-to-both-channels + --strict` passes. +- [ ] 4.5 **Human-only, and the owner's alone.** On npmjs.com the trusted + publisher for `@openspec-ui/cli` names `publish.yml` and the `npm` + environment, and the `npm` environment exists in the repository's + settings. Nothing in this change publishes anything, and no agent may + dispatch it. +- [ ] 4.6 **Human-only, and the owner's alone.** The first real npm + publish: dispatch `Publish` from `main` with `cli_version` and the + confirmation, and check the package page. diff --git a/scripts/check-publish-workflow.mjs b/scripts/check-publish-workflow.mjs index ca2158b1..ae5b4e40 100644 --- a/scripts/check-publish-workflow.mjs +++ b/scripts/check-publish-workflow.mjs @@ -19,7 +19,7 @@ import { fileURLToPath } from "node:url"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -export const PUBLISH_WORKFLOW = ".github/workflows/publish-marketplace.yml"; +export const PUBLISH_WORKFLOW = ".github/workflows/publish.yml"; /** The lines of a workflow, with comments and blank lines dropped: every * rule below is about what the file does, and a comment quoting a trigger @@ -68,14 +68,22 @@ export function checkPublishWorkflow(source) { if (!/!=\s*"publish"/u.test(text) && !/!=\s*'publish'/u.test(text)) { problems.push('no step refuses a run whose confirmation does not read "publish"'); } - if (!/^ {6}version:/mu.test(text)) { - problems.push('it takes no "version" input; a publish names the version it publishes'); + if (!/^ {6}vscode_version:/mu.test(text)) { + problems.push('it takes no "vscode_version" input; a Marketplace publish names the version it publishes'); + } + if (!/^ {6}cli_version:/mu.test(text)) { + problems.push('it takes no "cli_version" input; an npm publish names the version it publishes'); + } + // Both publishing jobs wait for the confirmation, so neither can read + // its credential before it has been checked. + if ((text.match(/^ {4}needs: confirm$/gmu) ?? []).length < 2) { + problems.push('a publishing job does not need the "confirm" job; it could read its credential unconfirmed'); } if (!text.includes("gh release download")) { problems.push("it does not take the VSIX from the release that version was tagged as"); } if (!/vsce publish[^\n]*--packagePath/u.test(text)) { - problems.push("its publish does not take --packagePath; what goes out must be the artifact that was released"); + problems.push("its Marketplace publish does not take --packagePath; what goes out must be the artifact that was released"); } if (/vsce package|npm run package/u.test(text)) { problems.push("it builds a package of its own rather than publishing the released one"); @@ -83,7 +91,27 @@ export function checkPublishWorkflow(source) { if (!text.includes("VSCE_PAT")) { problems.push("it reads no VSCE_PAT; nothing would authenticate"); } - + if (!/^ {2}npm:$/mu.test(text) || !/npm publish\b/u.test(text)) { + problems.push("it has no npm job that runs npm publish"); + } + if (!/^ {4}environment: npm$/mu.test(text)) { + problems.push('its npm job is not bound to the "npm" environment the trusted publisher names'); + } + if (!/^ {6}id-token: write$/mu.test(text)) { + problems.push("its npm job asks for no id-token; Trusted Publishing has nothing to authenticate with"); + } + if ((text.match(/id-token:\s*write/gu) ?? []).length > 1) { + problems.push("id-token: write is granted more than once; the OIDC token is held by the npm job alone"); + } + if (/NPM_TOKEN|NODE_AUTH_TOKEN/u.test(text)) { + problems.push("it names an npm token; npm is published by Trusted Publishing, and there is no token to leak"); + } + if (!text.includes("refs/heads/main")) { + problems.push("its npm job does not refuse a dispatch from a ref other than main"); + } + if (!/packages\/cli\/package\.json/u.test(text) || !text.includes("npm view")) { + problems.push("its npm job does not check the named version against the commit it builds, or against what is already on npm"); + } return problems; } @@ -120,6 +148,12 @@ export async function checkOtherWorkflows(root = repoRoot) { if (/vsce publish/u.test(text)) { problems.push(`${relative}: publishes to the Marketplace; that is the dispatched workflow's alone`); } + if (/npm publish|changeset publish/u.test(text)) { + problems.push(`${relative}: publishes to npm; that is the dispatched workflow's alone`); + } + if (/NPM_TOKEN|NODE_AUTH_TOKEN/u.test(text)) { + problems.push(`${relative}: names an npm token; npm is published by Trusted Publishing from the dispatched workflow alone`); + } } return problems; } @@ -130,7 +164,7 @@ export async function checkAll(root = repoRoot) { try { source = await readFile(path.join(root, PUBLISH_WORKFLOW), "utf8"); } catch { - return [`${PUBLISH_WORKFLOW} is missing; the Marketplace publish lives there`]; + return [`${PUBLISH_WORKFLOW} is missing; the Marketplace and npm publishes live there`]; } problems.push(...checkPublishWorkflow(source).map((problem) => `${PUBLISH_WORKFLOW}: ${problem}`)); problems.push(...(await checkOtherWorkflows(root))); @@ -142,7 +176,7 @@ async function main() { if (problems.length > 0) { console.error("Publish workflow check failed:\n"); for (const problem of problems) console.error(` ${problem}`); - console.error("\nSee openspec/changes/the-marketplace-publish-is-a-manual-step/design.md"); + console.error("\nSee openspec/changes/archive/2026-09-19-the-marketplace-publish-is-a-manual-step/design.md"); process.exit(1); } console.log("Publish workflow check passed."); diff --git a/scripts/check-publish-workflow.test.mjs b/scripts/check-publish-workflow.test.mjs index c0b5ee7a..5c658b70 100644 --- a/scripts/check-publish-workflow.test.mjs +++ b/scripts/check-publish-workflow.test.mjs @@ -10,28 +10,49 @@ import { checkAll, checkOtherWorkflows, checkPublishWorkflow } from "./check-pub // See a-check-that-passes-checked-something. const GOOD = [ - "name: Publish to the Marketplace", + "name: Publish", "", "on:", " workflow_dispatch:", " inputs:", - " version:", - " required: true", + " vscode_version:", + " required: false", + " cli_version:", + " required: false", " confirm:", " required: true", "", "jobs:", - " publish:", + " confirm:", " steps:", " - name: Refuse a run that was not confirmed", " run: |", ' if [ "${CONFIRM}" != "publish" ]; then exit 1; fi', + " marketplace:", + " needs: confirm", + " environment: marketplace", + " steps:", " - name: Take the VSIX from the release", " run: gh release download \"${TAG}\" --pattern '*.vsix' --dir publish", " - name: Publish it", " env:", " VSCE_PAT: ${{ secrets.VSCE_PAT }}", " run: npx vsce publish --packagePath \"${VSIX}\"", + " npm:", + " needs: confirm", + " environment: npm", + " permissions:", + " contents: read", + " id-token: write", + " steps:", + ' - run: if [ "${GITHUB_REF}" != "refs/heads/main" ]; then exit 1; fi', + " - name: Refuse a version that is not the one this commit carries", + " run: |", + " HAVE=$(node -p \"require('./packages/cli/package.json').version\")", + ' if npm view "@openspec-ui/cli@${VERSION}" version; then exit 1; fi', + " - name: Publish it", + " working-directory: packages/cli", + " run: npm publish --access public", ].join("\n"); function without(line) { @@ -79,11 +100,64 @@ test("building a package of its own fails", () => { assert.ok(checkPublishWorkflow(rebuilt).some((problem) => /builds a package of its own/u.test(problem))); }); +test("a missing vscode_version or cli_version input fails", () => { + assert.ok(checkPublishWorkflow(without(" vscode_version:")).some((problem) => /"vscode_version" input/u.test(problem))); + assert.ok(checkPublishWorkflow(without(" cli_version:")).some((problem) => /"cli_version" input/u.test(problem))); +}); + +test("a publishing job that does not wait for the confirmation fails", () => { + const unwaiting = GOOD.replace(" npm:\n needs: confirm", " npm:"); + assert.ok(checkPublishWorkflow(unwaiting).some((problem) => /does not need the "confirm" job/u.test(problem))); +}); + +test("an npm job outside the npm environment fails", () => { + const elsewhere = GOOD.replace(" environment: npm", " environment: marketplace"); + assert.ok(checkPublishWorkflow(elsewhere).some((problem) => /"npm" environment/u.test(problem))); +}); + +test("an npm job without id-token fails, and one granted twice fails too", () => { + assert.ok(checkPublishWorkflow(without(" id-token: write")).some((problem) => /asks for no id-token/u.test(problem))); + const twice = GOOD.replace(" needs: confirm\n environment: marketplace", " needs: confirm\n environment: marketplace\n permissions:\n id-token: write"); + assert.ok(checkPublishWorkflow(twice).some((problem) => /granted more than once/u.test(problem))); +}); + +test("an npm token anywhere in the workflow fails", () => { + const tokened = GOOD.replace(" run: npm publish --access public", " env:\n NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n run: npm publish --access public"); + assert.ok(checkPublishWorkflow(tokened).some((problem) => /names an npm token/u.test(problem))); +}); + +test("an npm job that does not refuse other refs, or check the version, fails", () => { + const anyRef = GOOD.replace('"refs/heads/main"', '"refs/heads/other"'); + assert.ok(checkPublishWorkflow(anyRef).some((problem) => /ref other than main/u.test(problem))); + const unchecked = GOOD.replace(' if npm view "@openspec-ui/cli@${VERSION}" version; then exit 1; fi', " echo ok"); + assert.ok(checkPublishWorkflow(unchecked).some((problem) => /check the named version/u.test(problem))); +}); + +test("a second workflow publishing to npm or naming an npm token fails, naming the file", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "openspec-publish-check-")); + try { + await mkdir(path.join(root, ".github", "workflows"), { recursive: true }); + await writeFile(path.join(root, ".github", "workflows", "publish.yml"), GOOD, "utf8"); + await writeFile( + path.join(root, ".github", "workflows", "quality.yml"), + ["name: Quality", "jobs:", " release:", " steps:", " - run: npm publish", " env:", " NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}"].join("\n"), + "utf8", + ); + + const problems = await checkOtherWorkflows(root); + + assert.equal(problems.length, 2); + assert.ok(problems.every((problem) => problem.startsWith(".github/workflows/quality.yml"))); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("a second workflow naming the token fails, naming the file", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "openspec-publish-check-")); try { await mkdir(path.join(root, ".github", "workflows"), { recursive: true }); - await writeFile(path.join(root, ".github", "workflows", "publish-marketplace.yml"), GOOD, "utf8"); + await writeFile(path.join(root, ".github", "workflows", "publish.yml"), GOOD, "utf8"); await writeFile( path.join(root, ".github", "workflows", "quality.yml"), ["name: Quality", "jobs:", " release:", " steps:", " - run: npx vsce publish", " env:", " VSCE_PAT: ${{ secrets.VSCE_PAT }}"].join("\n"), @@ -119,7 +193,7 @@ test("fails a workflow other than the dispatch one that names the homepage token const root = await mkdtemp(path.join(os.tmpdir(), "openspec-workflows-")); try { await mkdir(path.join(root, ".github", "workflows"), { recursive: true }); - await writeFile(path.join(root, ".github", "workflows", "publish-marketplace.yml"), GOOD, "utf8"); + await writeFile(path.join(root, ".github", "workflows", "publish.yml"), GOOD, "utf8"); await writeFile( path.join(root, ".github", "workflows", "quality.yml"), ["name: Quality", "jobs:", " build:", " env:", " GH_TOKEN: ${{ secrets.HOMEPAGE_DISPATCH_TOKEN }}"].join("\n"), @@ -139,7 +213,7 @@ test("allows the dispatch workflow itself to name the homepage token", async () const root = await mkdtemp(path.join(os.tmpdir(), "openspec-workflows-")); try { await mkdir(path.join(root, ".github", "workflows"), { recursive: true }); - await writeFile(path.join(root, ".github", "workflows", "publish-marketplace.yml"), GOOD, "utf8"); + await writeFile(path.join(root, ".github", "workflows", "publish.yml"), GOOD, "utf8"); await writeFile( path.join(root, ".github", "workflows", "homepage-dispatch.yml"), ["name: Tell the homepage", "jobs:", " tell:", " env:", " GH_TOKEN: ${{ secrets.HOMEPAGE_DISPATCH_TOKEN }}"].join("\n"),