From eedf08b2d7077c0968ded80c25520cfb9e2c811e Mon Sep 17 00:00:00 2001 From: Kristofer Date: Mon, 2 Nov 2026 11:26:55 +0100 Subject: [PATCH] release: publish to npm with Trusted Publishing, release each package independently Model the release workflow on the lexicon repository's: the Release Bot app token for the Release PR and the publish step, npm Trusted Publishing with provenance, and a tag and GitHub Release per package. The tag and release check now covers every released package instead of the server modules only, so a release that bumps only @hypercerts-org/api is not skipped. @hypercerts-org/api stays private and ignored until it builds against a published lexicon package. --- .agents/skills/writing-changesets/SKILL.md | 2 +- .changeset/README.md | 18 ++- .github/workflows/release.yml | 136 ++++++++++++++------- packages/api/README.md | 11 +- 4 files changed, 112 insertions(+), 55 deletions(-) diff --git a/.agents/skills/writing-changesets/SKILL.md b/.agents/skills/writing-changesets/SKILL.md index 6f836b7..9f87fa7 100644 --- a/.agents/skills/writing-changesets/SKILL.md +++ b/.agents/skills/writing-changesets/SKILL.md @@ -17,7 +17,7 @@ Do not add one for documentation-only or test-only changes, changes to the separ ## Package and version bump -The bundle is versioned as one unit. The Changeset frontmatter must target only `@hypercerts-org/happyview-modules`: +The bundle is versioned as one unit. The repository's other released package, the `@hypercerts-org/api` client, is not released yet and is in the Changesets `ignore` list (see `packages/api/README.md`), so until that changes the Changeset frontmatter must target only `@hypercerts-org/happyview-modules`: ```md --- diff --git a/.changeset/README.md b/.changeset/README.md index f35b8a3..2b1304f 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -1,13 +1,21 @@ -# API bundle changesets +# Changesets -Add a Changeset when a pull request changes the installable API bundle or its operator-visible behavior. Run: +This repository releases two packages, each with its own version: + +- `@hypercerts-org/happyview-modules`: the installable API bundle (Lua modules, Lexicons and installer). Released as a Git tag and GitHub Release, not published to npm; operators retrieve the tagged source from this public repository and install it into their own HappyView instance. +- `@hypercerts-org/api`: the TypeScript query definitions and types for clients. Published to npm, with a Git tag and GitHub Release. Not released yet; see [packages/api/README.md](../packages/api/README.md#versioning-and-releases). + +Add a Changeset when a pull request changes what either package ships. Run: ```sh pnpm changeset ``` -Target `@hypercerts-org/happyview-modules` and write release notes for endpoint or schema additions, compatible behavior changes, fixes, breaking changes, and installer or configuration changes that operators need to know about. Use `patch` for compatible fixes, `minor` for additive API capabilities, and `major` for incompatible API changes. +and select each package the pull request changes. Use `patch` for compatible fixes, `minor` for additive capabilities, and `major` for incompatible changes. + +- For `@hypercerts-org/happyview-modules`, write release notes for endpoint or schema additions, compatible behavior changes, fixes, breaking changes, and installer or configuration changes that operators need to know about. +- For `@hypercerts-org/api`, write release notes for client-visible changes: new or changed queries, parameters and types, and the `@hypercerts-org/lexicon` version it builds on. An API change that touches both the modules and the generated client gets an entry for each package. -The API bundle is versioned as one unit. Do not add Changesets for the separate endpoint-explorer package, documentation-only changes, tests, or release/CI maintenance that does not change the installable bundle. If the release impact is unclear, decide with the reviewer rather than guessing a version bump. +Do not add Changesets for the separate endpoint-explorer package, documentation-only changes, tests, or release/CI maintenance that does not change what a package ships. If the release impact is unclear, decide with the reviewer rather than guessing a version bump. -A Changesets release PR updates `happyview-modules/package.json` and `happyview-modules/CHANGELOG.md`. After it is merged to `main`, release CI validates that exact commit and creates an `@hypercerts-org/happyview-modules@X.Y.Z` Git tag and public GitHub Release. The bundle is not published to npm; operators retrieve the tagged source from this public repository and install it into their own HappyView instance. +A Changesets release PR updates each bumped package's `package.json` and `CHANGELOG.md`. After it is merged to `main`, release CI validates that exact commit, publishes the bumped public packages to npm, and creates a `@X.Y.Z` Git tag and public GitHub Release for each released version. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c339ffe..c7971e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,15 +55,26 @@ jobs: needs: select-mode runs-on: ubuntu-latest permissions: - contents: write - pull-requests: write + contents: read steps: + # The Release Bot app, as in the lexicon repository: a Release PR opened + # with GITHUB_TOKEN would not trigger CI. + - name: Generate Release Bot app token + id: generate-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ secrets.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} + token: ${{ steps.generate-token.outputs.token }} fetch-depth: 0 - persist-credentials: false + persist-credentials: true - name: Set up pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 @@ -80,17 +91,19 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Version bundle and changelog + - name: Version packages and changelogs uses: changesets/action/version@8488615a623b1b9c987934bb89eae8af6a946ac1 # v2.1.1 with: - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ steps.generate-token.outputs.token }} script: pnpm version:packages - commit-message: 'release: version API bundle' - pr-title: Release API bundle + commit-message: 'release: version packages' + pr-title: Release packages pr-base-branch: main + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} publish: - name: Validate and publish GitHub Release + name: Validate and publish releases if: >- github.event_name == 'pull_request' && github.event.pull_request.merged == true && @@ -98,8 +111,19 @@ jobs: github.event.pull_request.head.ref == 'changeset-release/main' runs-on: ubuntu-latest permissions: - contents: write + contents: read + # npm Trusted Publishing: npm exchanges the workflow's OIDC token for a + # short-lived publish token, so the repository holds no npm token. + id-token: write steps: + - name: Generate Release Bot app token + id: generate-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ secrets.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_APP_PRIVATE_KEY }} + permission-contents: write + - name: Check out exact merged commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -149,17 +173,18 @@ jobs: node-version-file: .nvmrc cache: pnpm cache-dependency-path: pnpm-lock.yaml + registry-url: https://registry.npmjs.org - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Check API bundle + - name: Check packages run: pnpm check - - name: Build API bundle + - name: Build packages run: pnpm build - - name: Check existing tag and GitHub Release + - name: Check existing tags and GitHub Releases id: preflight env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -167,13 +192,18 @@ jobs: GITHUB_REPOSITORY: ${{ github.repository }} run: | set -euo pipefail - package_name="$(node --print "require('./happyview-modules/package.json').name")" - version="$(node --print "require('./happyview-modules/package.json').version")" - if [ -z "$package_name" ] || [ -z "$version" ]; then - echo "::error::Could not derive the API bundle name and version from happyview-modules/package.json" - exit 1 - fi - tag="${package_name}@${version}" + # Every released package, as name@version: the workspace packages + # that Changesets does not ignore. + tags="$(node --input-type=module --eval ' + import { readFileSync } from "node:fs"; + const read = (file) => JSON.parse(readFileSync(file, "utf8")); + const ignored = new Set(read(".changeset/config.json").ignore); + for (const manifest of ["happyview-modules/package.json", "packages/api/package.json"]) { + const { name, version } = read(manifest); + if (!name || !version) throw new Error(`No name or version in ${manifest}`); + if (!ignored.has(name)) console.log(`${name}@${version}`); + } + ')" api_url="${GITHUB_API_URL%/}" get_status() { @@ -184,41 +214,55 @@ jobs: --header "Authorization: Bearer ${GH_TOKEN}" \ --header 'X-GitHub-Api-Version: 2022-11-28' \ "${api_url}/${endpoint}")"; then - echo "::error::Unable to query GitHub while checking ${tag}" >&2 + echo "::error::Unable to query GitHub while checking ${endpoint}" >&2 exit 1 fi printf '%s' "$status" } - tag_status="$(get_status "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}")" - release_status="$(get_status "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}")" - case "${tag_status}:${release_status}" in - 200:200) - echo "::notice::${tag} already has both a remote tag and GitHub Release; nothing to publish." - echo 'publish=false' >> "$GITHUB_OUTPUT" - ;; - 404:404) - echo "No existing tag or GitHub Release found for ${tag}; publishing is required." - echo 'publish=true' >> "$GITHUB_OUTPUT" - ;; - 200:404) - echo "::error::Release state is inconsistent for ${tag}: the remote tag exists but the GitHub Release is missing. Reconcile the tag and Release manually, then rerun this workflow." - exit 1 - ;; - 404:200) - echo "::error::Release state is inconsistent for ${tag}: the GitHub Release exists but the remote tag is missing. Reconcile the tag and Release manually, then rerun this workflow." - exit 1 - ;; - *) - echo "::error::Unexpected GitHub API response while checking ${tag}: tag=${tag_status}, release=${release_status}" - exit 1 - ;; - esac + # Publishing runs when at least one package version has no tag and + # GitHub Release yet; Changesets skips the versions already released. + publish=false + for tag in $tags; do + tag_status="$(get_status "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}")" + release_status="$(get_status "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}")" + case "${tag_status}:${release_status}" in + 200:200) + echo "${tag} already has both a remote tag and GitHub Release." + ;; + 404:404) + echo "No existing tag or GitHub Release found for ${tag}; publishing is required." + publish=true + ;; + 200:404) + echo "::error::Release state is inconsistent for ${tag}: the remote tag exists but the GitHub Release is missing. Reconcile the tag and Release manually, then rerun this workflow." + exit 1 + ;; + 404:200) + echo "::error::Release state is inconsistent for ${tag}: the GitHub Release exists but the remote tag is missing. Reconcile the tag and Release manually, then rerun this workflow." + exit 1 + ;; + *) + echo "::error::Unexpected GitHub API response while checking ${tag}: tag=${tag_status}, release=${release_status}" + exit 1 + ;; + esac + done + if [ "$publish" = false ]; then + echo "::notice::Every package version already has a tag and GitHub Release; nothing to publish." + fi + echo "publish=${publish}" >> "$GITHUB_OUTPUT" - - name: Create tag and GitHub Release + # Changesets publishes public packages to npm (through `pnpm publish`, + # which hands the tarball to the npm CLI bundled with Node 24, new enough + # for Trusted Publishing), tags private packages, and creates a tag and + # GitHub Release for each released version. + - name: Publish to npm, create tags and GitHub Releases if: steps.preflight.outputs.publish == 'true' uses: changesets/action/publish@8488615a623b1b9c987934bb89eae8af6a946ac1 # v2.1.1 with: - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ steps.generate-token.outputs.token }} create-github-releases: true push-git-tags: true + env: + NPM_CONFIG_PROVENANCE: true diff --git a/packages/api/README.md b/packages/api/README.md index 0433d48..f10a56f 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -1,6 +1,6 @@ # @hypercerts-org/api -TypeScript query definitions and types for the Hypercerts API, for use with [`@atproto/lex`](https://www.npmjs.com/package/@atproto/lex). Not published to npm yet; use it from this workspace, or from a tarball made with `pnpm pack` (which builds first). A git dependency does not work: `dist/` is not committed. +TypeScript query definitions and types for the Hypercerts API, for use with [`@atproto/lex`](https://www.npmjs.com/package/@atproto/lex). Not published to npm yet (see [Versioning and releases](#versioning-and-releases)); use it from this workspace, or from a tarball made with `pnpm pack` (which builds first). A git dependency does not work: `dist/` is not committed. ```ts import { Client } from "@atproto/lex"; @@ -61,9 +61,14 @@ Run from `packages/api/`: Tooling follows the SDK repository (TECH-748): Biome, TypeScript 7, ESM, Node 24 or later. `exactOptionalPropertyTypes` is off because code generated by `@atproto/lex` 0.4.2 does not compile with it. -## Versioning +## Versioning and releases -The package is not versioned or released yet: it is listed in the Changesets `ignore` list, because the release workflow only tags the API module bundle (TECH-818). Releases start when the workflow supports more than one package. +The package is versioned with Changesets, independently of the API modules. The release workflow publishes it to npm with [Trusted Publishing](https://docs.npmjs.com/trusted-publishers) and provenance, and creates an `@hypercerts-org/api@X.Y.Z` tag and GitHub Release, when a Release PR that bumps it is merged. + +It is not released yet: it is `private` and listed in the Changesets `ignore` list until it builds against a published lexicon package. To start releasing it: + +1. Configure a Trusted Publisher for `@hypercerts-org/api` on npmjs.com: repository `hypercerts-org/api`, workflow `release.yml`. If npm requires the package to exist first, an owner of the `@hypercerts-org` npm scope publishes the first version by hand. +2. In one pull request: remove `"private": true`, remove the package from `ignore` in `.changeset/config.json`, and add a changeset for it. ## License