diff --git a/.claude/commands/create-pr.md b/.claude/commands/create-pr.md index 4d9eafb..6993909 100644 --- a/.claude/commands/create-pr.md +++ b/.claude/commands/create-pr.md @@ -11,7 +11,7 @@ The previous flow outsourced PR-description authoring to a GitHub Action that on This is `@robosystems/client` — a **published, post-1.0 TypeScript SDK**, largely generated from the RoboSystems API's OpenAPI document and GraphQL schema. Its description is read by integrators deciding whether an upgrade is safe, so precision about the public surface matters more here than in an application repo. -**This repository is public.** The PR title and body are world-readable the moment they're pushed — and, because publishing is triggered by a push to `release/**` rather than by a merge, the text is often public well before the version that carries it. Treat the description as a publication. +**This repository is public.** The PR title and body are world-readable the moment they're pushed — and, because publishing happens only when a release is dispatched rather than on merge, the text is often public well before the version that carries it. Treat the description as a publication. ## Instructions @@ -26,7 +26,7 @@ TARGET=${1:-main} # override target via the first argument ``` - **Never PR from the default branch.** If `CURRENT` is `main` (or `master`/`staging`), stop and tell the user to switch to a feature branch first. New branches are created via `npm run feature:create`, not by hand. -- **Never target a release branch.** `release/**` is what `publish.yml` watches; a PR into one is a publish trigger, not a code review. Target `main`. +- **Never target a release branch.** `release/` is a cut record that `create-release.yml` tags and publishes from; a PR into one changes nothing on `main` and is not a code review. Target `main`. - **Source ≠ target.** If `CURRENT == TARGET`, stop. - **Uncommitted changes.** Run `git status --porcelain`. If there are uncommitted/staged changes, surface them and ask whether to commit them (respecting the repo's commit rules — never on `main`, stage files by name, no `git add -A`) or proceed without them. The PR description must reflect committed state. - **Existing PR.** Check `gh pr list --head "$CURRENT" --base "$TARGET" --json url,number`. If a PR already exists, do **not** create a duplicate — offer to update its title/body with `gh pr edit` instead. @@ -66,7 +66,7 @@ This is the whole point — ground the description in what actually happened: A regeneration is not automatically additive: an API schema change can narrow a type or drop a field, and that reaches consumers as a break even though no hand-written line changed. Diff the emitted types before classifying. -- **Version and publish are not this PR's job.** `create-release.yml` bumps the version on `main` and cuts `release/`; the push to that branch is what triggers `publish.yml`. Never bump `package.json` in a feature PR and never imply the PR publishes anything. +- **Version and publish are not this PR's job.** `create-release.yml` bumps the version on `main` and cuts `release/`, tags it, and publishes from the tag in the same run. Never bump `package.json` in a feature PR and never imply the PR publishes anything. - **Security-fix disclosure.** If the PR fixes a security issue, the prose is often _more_ actionable than the diff — keep it terse and non-actionable. Name the area hardened, never the mechanism. No exploit mechanics, attack scenarios, endpoint enumerations, or payloads. For coordinated disclosure use a private GitHub Security Advisory, never a public issue. diff --git a/.claude/commands/publish.md b/.claude/commands/publish.md index a493285..7857c23 100644 --- a/.claude/commands/publish.md +++ b/.claude/commands/publish.md @@ -7,12 +7,13 @@ Monitor a release and publish run — pinpoint why it failed, and confirm the ve ## How a release actually happens here -Two workflows, and the trigger between them is the part that surprises people: +One workflow, `create-release.yml` (`workflow_dispatch`, or `npm run release:create`), with three jobs in sequence: -1. **`create-release.yml`** (`workflow_dispatch`, or `npm run release:create`) — reads the current version from `package.json`, computes the next one from the requested bump, commits the bump **to `main`**, cuts `release/` from that commit, and tags it. -2. **`publish.yml`** — triggered by **a push to `release/**`**, not by a merge and not by the tag. It reads the version from `package.json`, checks whether that version already exists on npm, and if not, publishes with `npm publish --provenance --access public` over OIDC trusted publishing. +1. **`create-release`** — reads the current version from `package.json`, computes the next one from the requested bump, commits the bump **to `main`**, and cuts `release/` from that commit. +2. **`create-tag`** — `tag-release.yml` tags that branch and cuts the GitHub Release. +3. **`publish`** — runs once the tag exists, under the `production` environment, so its required-reviewer prompt lands on this same run. It checks out the tag, verifies the version is the one just cut, checks whether that version already exists on npm, and if not, publishes with `npm publish --provenance --access public` over OIDC trusted publishing. npm's trusted publisher is registered against `create-release.yml` and the `production` environment. -So: **merging a PR to `main` publishes nothing.** The release branch push is the publishing event. And because `publish.yml` short-circuits when the version already exists on npm, a re-run of a successful publish is a no-op rather than an error — useful, but it also means "the run went green" is not by itself proof that _this_ run published anything. +So: **merging a PR to `main` publishes nothing.** Dispatching the workflow is the publishing event, and nothing uploads until the deployment is approved on the run page. A failed publish is retried with **Re-run failed jobs** on that run — the version and tag are already cut, and a new release is warranted only when the code itself is at fault. Because the job short-circuits when the version already exists on npm, a re-run after a successful publish is a no-op rather than an error — useful, but it also means "the run went green" is not by itself proof that _this_ run published anything. `tag-release.yml` writes the GitHub release body separately; see `/release-notes` for the curated-notes override. @@ -26,7 +27,6 @@ So: **merging a PR to `main` publishes nothing.** The release branch push is the ## 1. Find the run ```bash -gh run list --workflow=publish.yml --limit 5 gh run list --workflow=create-release.yml --limit 5 gh run view gh run watch # live, if it's in flight @@ -42,10 +42,11 @@ Classify by stage: - **`create-release.yml` — branch already exists.** The workflow checks for `release/` before creating it. A failure here usually means a previous run got partway, and the fix is to resolve the leftover branch, not to re-dispatch blindly. - **`create-release.yml` — push to `main` rejected.** The version bump commits directly to a protected branch and needs `ACTIONS_TOKEN`; a permissions failure here looks like an auth error at the push step. -- **`publish.yml` — "already published".** Not a failure. The version exists on npm, so every subsequent step is skipped by condition. Read it as "nothing to do," and if you expected a publish, the version wasn't bumped. -- **`publish.yml` — install or build.** `npm install` then `prepare:publish` (which runs the `tsc` build). A build failure here is a real code problem that CI on `main` should have caught — check whether the release branch carries something `main` didn't. -- **`publish.yml` — regeneration.** Only runs when dispatched manually with `regenerate: true`, and it generates against the **production API** (`https://api.robosystems.ai`). A failure means the API was unreachable or its schema changed under you; a _success_ here is more dangerous, because it can publish types nobody reviewed. Prefer regenerating in a PR. -- **`publish.yml` — `npm publish`.** OIDC trusted publishing with provenance. Failures are usually the npm-side trust configuration or a version/name mismatch, not the code. +- **`publish` — waiting.** The job is paused on the `production` environment's required reviewer. Nothing is wrong; approve it from the run page (_Review deployments_). +- **`publish` — "checked out version X, expected Y".** The tag the job checked out does not carry the version this run cut. Nothing was published; look at what `create-tag` tagged. +- **`publish` — "already published".** Not a failure. The version exists on npm, so every subsequent step is skipped by condition. Read it as "nothing to do," and if you expected a publish, the version wasn't bumped. +- **`publish` — install or build.** `npm install` then `prepare:publish` (which runs the `tsc` build). A build failure here is a real code problem that CI on `main` should have caught — check whether the release branch carries something `main` didn't. +- **`publish` — `npm publish`.** OIDC trusted publishing with provenance. Failures are usually the npm-side trust configuration (the publisher must name `create-release.yml`) or a version/name mismatch, not the code. ## 3. Verify it actually landed @@ -67,6 +68,6 @@ If the version is a major, the consuming apps (`robosystems-app`, `roboledger-ap ## Output -A short status: which workflow, what failed and at which step, the root cause, the re-run link if any, and the verified published version from `npm view`. If nothing failed, say so — don't manufacture work. +A short status: which job, what failed and at which step, the root cause, the re-run link if any, and the verified published version from `npm view`. If nothing failed, say so — don't manufacture work. $ARGUMENTS diff --git a/.claude/commands/release-notes.md b/.claude/commands/release-notes.md index 9ba286d..d700814 100644 --- a/.claude/commands/release-notes.md +++ b/.claude/commands/release-notes.md @@ -60,6 +60,6 @@ Write `.github/release-notes/v.md` — **body only**: ### 6. Hand off — sequencing matters -The file must exist **at the tagged ref**, and there is no window to add it late: `create-release.yml` bumps the version on `main`, cuts `release/` from the result, and tags it in the same run. Pushing that release branch is also what triggers `publish.yml`, so by the time the package is on npm the notes are already fixed. They have to be **merged into `main` before the workflow is dispatched**. +The file must exist **at the tagged ref**, and there is no window to add it late: `create-release.yml` bumps the version on `main`, cuts `release/` from the result, and tags it in the same run. The same run then publishes from that tag, so by the time the package is on npm the notes are already fixed. They have to be **merged into `main` before the workflow is dispatched**. Write the draft on a feature branch (created via `npm run feature:create`), never on `main`. Present it for review and leave the merge and the dispatch to the user. diff --git a/.claude/commands/staged-review.md b/.claude/commands/staged-review.md index 5068ad4..424c67c 100644 --- a/.claude/commands/staged-review.md +++ b/.claude/commands/staged-review.md @@ -45,7 +45,7 @@ Post-1.0, the emitted type surface **is** the contract. For anything staged here ## Packaging - Changes to `prepare.js`, `package.json` `files`/`exports`/`types`, or the build output change **what ships**. A wrong `exports` map breaks consumers in ways nothing in this repo tests — cross-check against how the apps actually import. -- Never stage a `package.json` version bump in a feature branch: `create-release.yml` owns the bump on `main`, and pushing `release/**` is what triggers `publish.yml`. +- Never stage a `package.json` version bump in a feature branch: `create-release.yml` owns the bump on `main` and publishes from the tag it cuts. ## Testing diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 6d6f2a5..d9631c5 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -130,8 +130,90 @@ jobs: project_kind: 'TypeScript SDK' secrets: inherit - create-summary: + # npm's trusted publisher is pinned to this workflow file and the production + # environment, whose protection rules (required reviewer, deployment + # branches) gate every publish. The approval prompt lands on this run. + publish: needs: [create-release, create-tag] + runs-on: ubuntu-24.04 + environment: production + + permissions: + contents: read + id-token: write + + steps: + - name: Checkout release tag + uses: actions/checkout@v7 + with: + ref: ${{ needs.create-tag.outputs.tag_name }} + + - name: Get version from package.json + id: version + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Current version: $VERSION" + + - name: Verify the checkout is the released version + run: | + EXPECTED="${{ needs.create-release.outputs.new_version }}" + ACTUAL="${{ steps.version.outputs.version }}" + + if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "❌ Checked out version $ACTUAL, expected $EXPECTED" + exit 1 + fi + echo "✅ Checked out the released version $ACTUAL" + + - name: Check if already published + id: check-published + run: | + # Check if this version is already published to npm + if npm view @robosystems/client@${{ steps.version.outputs.version }} version 2>/dev/null; then + echo "already_published=true" >> $GITHUB_OUTPUT + echo "Version ${{ steps.version.outputs.version }} is already published" + else + echo "already_published=false" >> $GITHUB_OUTPUT + echo "Version ${{ steps.version.outputs.version }} is not yet published" + fi + + - name: Setup Node.js + if: steps.check-published.outputs.already_published == 'false' + uses: actions/setup-node@v7 + with: + node-version: '24' + + - name: Install dependencies + if: steps.check-published.outputs.already_published == 'false' + run: npm install + + # Skip build and tests - this is a Typescript Client that users build + # The prepare script will handle necessary file generation for publishing + + - name: Prepare for publishing + if: steps.check-published.outputs.already_published == 'false' + run: npm run prepare:publish + + - name: Publish to npm + if: steps.check-published.outputs.already_published == 'false' + run: npm publish --provenance --access public + + - name: Create summary + if: steps.check-published.outputs.already_published == 'false' + run: | + echo "## 📦 Published to npm" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Package:** [@robosystems/client](https://www.npmjs.com/package/@robosystems/client)" >> $GITHUB_STEP_SUMMARY + echo "**Version:** v${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Installation" >> $GITHUB_STEP_SUMMARY + echo '```bash' >> $GITHUB_STEP_SUMMARY + echo "npm install @robosystems/client@${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + + create-summary: + needs: [create-release, create-tag, publish] if: always() runs-on: ubuntu-24.04 permissions: @@ -145,10 +227,11 @@ jobs: echo "**Tag:** \`${{ needs.create-tag.outputs.tag_name }}\`" >> $GITHUB_STEP_SUMMARY echo "**Branch:** \`${{ needs.create-release.outputs.branch_name }}\`" >> $GITHUB_STEP_SUMMARY echo "**Type:** ${{ inputs.version_type }}" >> $GITHUB_STEP_SUMMARY + echo "**Publish:** ${{ needs.publish.result }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Release URL:** ${{ needs.create-tag.outputs.release_url }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Next Steps" >> $GITHUB_STEP_SUMMARY - echo "The npm package will be automatically published when the release tag is created." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Monitor the publish workflow: [View Workflows](https://github.com/RoboFinSystems/robosystems-typescript-client/actions/workflows/publish.yml)" >> $GITHUB_STEP_SUMMARY \ No newline at end of file + + if [ "${{ needs.publish.result }}" != "success" ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "The version and tag are cut but the package is not on npm. Fix the cause, then **Re-run failed jobs** on this run; a new release is only needed when the code itself is at fault." >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 4b5696e..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: Publish to npm - -on: - push: - branches: - - 'release/**' - workflow_dispatch: - inputs: - regenerate: - description: 'Regenerate SDK before publishing' - required: false - type: boolean - default: false - -jobs: - publish: - runs-on: ubuntu-24.04 - # The production environment's protection rules (required reviewer, - # deployment branches) gate every publish here. - environment: production - - permissions: - contents: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Get version from package.json - id: version - run: | - VERSION=$(node -p "require('./package.json').version") - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Current version: $VERSION" - - - name: Check if already published - id: check-published - run: | - # Check if this version is already published to npm - if npm view @robosystems/client@${{ steps.version.outputs.version }} version 2>/dev/null; then - echo "already_published=true" >> $GITHUB_OUTPUT - echo "Version ${{ steps.version.outputs.version }} is already published" - else - echo "already_published=false" >> $GITHUB_OUTPUT - echo "Version ${{ steps.version.outputs.version }} is not yet published" - fi - - - name: Setup Node.js - if: steps.check-published.outputs.already_published == 'false' - uses: actions/setup-node@v7 - with: - node-version: '24' - - - name: Install dependencies - if: steps.check-published.outputs.already_published == 'false' - run: npm install - - - name: Regenerate SDK (if requested) - if: steps.check-published.outputs.already_published == 'false' && github.event.inputs.regenerate == 'true' - env: - ROBOSYSTEMS_API_URL: 'https://api.robosystems.ai' - run: npm run generate - - # Skip build and tests - this is a Typescript Client that users build - # The prepare script will handle necessary file generation for publishing - - - name: Prepare for publishing - if: steps.check-published.outputs.already_published == 'false' - run: npm run prepare:publish - - - name: Publish to npm - if: steps.check-published.outputs.already_published == 'false' - run: npm publish --provenance --access public - - - name: Create summary - if: steps.check-published.outputs.already_published == 'false' - run: | - echo "## 📦 Published to npm" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Package:** [@robosystems/client](https://www.npmjs.com/package/@robosystems/client)" >> $GITHUB_STEP_SUMMARY - echo "**Version:** v${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Installation" >> $GITHUB_STEP_SUMMARY - echo '```bash' >> $GITHUB_STEP_SUMMARY - echo "npm install @robosystems/client@${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY \ No newline at end of file