From 70fd299e4357b12c91149ecd4cce637e6e065769 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 28 Oct 2026 10:14:55 -0700 Subject: [PATCH] ci: release from a GitHub Actions workflow and merge the sync PRs Add a Release workflow that a maintainer runs from the Actions tab. It runs release-it on main as the pluginpack-sync app, so the release commit can bypass the main ruleset and the GitHub Release triggers publish.yml. publish.yml now merges each sync PR after it syncs, and runs sync mode only on a release tag, so it never publishes unreleased code. A before:init hook stops a release when GITHUB_TOKEN is missing. Without it, release-it opens a browser form and reports success while no release exists. --- .github/workflows/publish.yml | 44 +++++++++++++++--- .github/workflows/release.yml | 82 +++++++++++++++++++++++++++++++++ .release-it.json | 1 + RELEASE.md | 80 +++++++++++++++++++++----------- scripts/check-release-token.mjs | 16 +++++++ 5 files changed, 190 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/check-release-token.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d063682..72047cb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,18 +1,20 @@ name: Publish plugins -# Sync generated plugin output into the published install-surface repos by -# opening a PR in each. Runs on a published release; can also be dispatched -# manually (use mode=check for a dry staleness check). +# Sync generated plugin output into the published install-surface repos. +# Runs on a published release: opens or updates a sync PR in each output repo, +# then merges it. Can also be dispatched by hand: check mode from any ref +# (fails if any output is stale), and sync mode from a release tag only, to +# re-run a release's sync. on: release: types: [published] workflow_dispatch: inputs: mode: - description: sync (open PRs) or check (fail if any output is stale) + description: check (fail if any output is stale) or sync (release tags only) type: choice - options: [sync, check] - default: sync + options: [check, sync] + default: check permissions: contents: read @@ -22,6 +24,15 @@ jobs: name: Validate all targets runs-on: ubuntu-latest steps: + # Sync mode publishes to the output repos and merges the sync PRs, so it + # only runs on released code. Unreleased code reaches users by cutting a + # release. + - name: Sync only from a release tag + if: github.event_name == 'workflow_dispatch' && inputs.mode == 'sync' && !startsWith(github.ref, 'refs/tags/v') + run: | + echo "::error::sync mode updates the published plugin repos, so it runs only on a release tag. Choose a v* tag under 'Use workflow from', or run check mode." + exit 1 + - name: Checkout source uses: actions/checkout@v5 @@ -91,6 +102,7 @@ jobs: repositories: ${{ matrix.repo }} - name: Sync ${{ matrix.target }} + id: sync # Pinned pre-v1 (Actions requires the full SHA); repoint to @v1 after # the first proven sync. uses: gleanwork/pluginpack-action@52a7c266ffa90d35289f2a34e83db028c7945ccc @@ -100,3 +112,23 @@ jobs: token: ${{ steps.app-token.outputs.token }} pluginpack-version: ${{ steps.pp.outputs.version }} mode: ${{ inputs.mode || 'sync' }} + + # The sync PR is built from a reviewed, validated release tag, so merge + # it here instead of by hand. pluginpack-sync must be allowed to bypass + # pull request reviews in the output repo. The action sets pr-number in + # sync mode only, and only when the output changed. + - name: Merge sync PR + if: steps.sync.outputs.pr-number != '' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: gleanwork/${{ matrix.repo }} + PR: ${{ steps.sync.outputs.pr-number }} + run: | + for _ in 1 2 3; do + if gh pr merge "$PR" --repo "$REPO" --squash --admin; then + exit 0 + fi + sleep 10 + done + echo "::error::Couldn't merge $REPO#$PR. Check that pluginpack-sync can bypass pull request reviews in $REPO, then merge the PR by hand." + exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f49a22f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,82 @@ +name: Release + +# One-click release. A maintainer runs this from the Actions tab on main. It +# runs release-it, which bumps the version, writes the changelog, commits, +# tags, pushes to main, and creates the GitHub Release. The release then +# triggers publish.yml, which syncs the plugin repos. +on: + workflow_dispatch: + inputs: + increment: + description: Version increment. "auto" picks it from the commits since the last tag. + type: choice + options: [auto, patch, minor, major] + default: auto + dry-run: + description: Dry run. Shows the version and changelog without committing, tagging, or releasing. + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Require main + if: github.ref != 'refs/heads/main' + run: | + echo "::error::Releases run from main only. Choose main under 'Use workflow from'." + exit 1 + + # The release commit goes straight to main, so it needs an actor that + # the main ruleset lets bypass pull requests. GITHUB_TOKEN can't, and a + # release created with GITHUB_TOKEN wouldn't trigger publish.yml. + - name: Mint release token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ secrets.PLUGINPACK_CLIENT_ID }} + private-key: ${{ secrets.PLUGINPACK_APP_KEY }} + + - name: Checkout main + uses: actions/checkout@v5 + with: + token: ${{ steps.app-token.outputs.token }} + # release-it reads the commits and tags since the last release. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + + - name: Install + run: npm ci + + - name: Commit as the app + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + run: | + user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" + git config user.name "${APP_SLUG}[bot]" + git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" + + - name: Run release-it + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + INCREMENT: ${{ inputs.increment }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + args=(--ci) + if [ "$INCREMENT" != auto ]; then args+=("$INCREMENT"); fi + if [ "$DRY_RUN" = true ]; then args+=(--dry-run); fi + npx release-it "${args[@]}" diff --git a/.release-it.json b/.release-it.json index 7b2f072..1347c26 100644 --- a/.release-it.json +++ b/.release-it.json @@ -11,6 +11,7 @@ "publish": false }, "hooks": { + "before:init": "node scripts/check-release-token.mjs", "before:git:beforeRelease": "npm run build" }, "plugins": { diff --git a/RELEASE.md b/RELEASE.md index 2ad3648..b362b22 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -29,31 +29,32 @@ release is what triggers the actual plugin distribution (below). commit message (GitHub defaults this to the PR title). That message is now the _only_ thing conventional-changelog sees for the whole PR, so the PR title itself must be a valid Conventional Commit message. -- Make sure you're releasing from an up-to-date `main` with a clean working - tree and dependencies installed: +## Release - ```sh - git checkout main - git pull - npm install - ``` +Run the **Release** workflow +([`.github/workflows/release.yml`](./.github/workflows/release.yml)) from the +Actions tab, on `main`: -## Release +1. Run it with **Dry run** checked (the default). The log shows the next + version and the changelog. +2. If that looks right, run it again with **Dry run** unchecked. -- `release-it`'s GitHub plugin needs a token with `repo` scope to create the - GitHub Release (this is the event the plugin sync below listens for): +Leave **Version increment** on `auto` unless you need to override the +version that the commits imply. - ```sh - export GITHUB_TOKEN=ghp_... - ``` +From the command line: -- Run the release: +```sh +gh workflow run release.yml --repo gleanwork/agent-plugins --ref main +gh workflow run release.yml --repo gleanwork/agent-plugins --ref main -f dry-run=false +``` - ```sh - npm run release - ``` +The workflow runs `release-it` as the `pluginpack-sync` GitHub App. The app +is the only actor the `main` ruleset lets push without a pull request, and a +release the app creates triggers the publish workflow. A release created with +the workflow's own `GITHUB_TOKEN` would not. - `release-it` will: +`release-it` will: 1. Determine the next version from commits since the last tag (`feat` → minor, `fix` → patch, a `BREAKING CHANGE` footer → major). @@ -74,6 +75,23 @@ release is what triggers the actual plugin distribution (below). Nothing here is published to the npm registry — `npm.publish: false` in [`.release-it.json`](./.release-it.json). +### Releasing from your machine + +Use the workflow. A local `npm run release` works only if you can push to +`main` without a pull request, and it needs `GITHUB_TOKEN` set to a token +with `repo` scope. Without the token, `release-it` would open a release form +in your browser and report success while no release exists, so the +`before:init` hook stops the release instead. + +Run it from an up-to-date `main` with a clean working tree: + +```sh +git checkout main +git pull +npm ci +GITHUB_TOKEN="$(gh auth token)" npm run release +``` + ## What happens next (automatic) Publishing the GitHub Release triggers @@ -84,18 +102,26 @@ Publishing the GitHub Release triggers repo (`gleanwork/claude-plugins`, `gleanwork/cursor-plugins`, `gleanwork/codex-plugins`), on a machine-owned `pluginpack/sync-` branch labeled `pluginpack-sync`. +3. Squash-merges each sync PR. Users get the release once these merge. + +The merge step needs `pluginpack-sync` to be allowed to bypass pull request +reviews in each output repo. If a merge fails, the job says which PR to +merge by hand. -Those PRs are regenerated and force-pushed on every sync — **don't push to -them by hand or hand-edit their content**. Review and merge each one in its -own repo once it looks right. +The sync PRs are regenerated and force-pushed on every sync — **don't push +to them by hand or hand-edit their content**. -If you need those PRs to pick up a fix made on `main` _without_ cutting a new -release, dispatch the workflow directly instead of running `release-it` -again: +To get a fix on `main` to users, cut a release. Sync mode runs only on a +release tag, so it never publishes unreleased code. To re-run a release's +sync, for example after a failed job, dispatch the workflow on that tag: ```sh -gh workflow run publish.yml --repo gleanwork/agent-plugins -f mode=sync +gh workflow run publish.yml --repo gleanwork/agent-plugins --ref v3.6.0 -f mode=sync ``` -Use `-f mode=check` instead to only report staleness without opening or -updating anything. +Check mode runs on any ref and only reports whether the output repos are +stale: + +```sh +gh workflow run publish.yml --repo gleanwork/agent-plugins -f mode=check +``` diff --git a/scripts/check-release-token.mjs b/scripts/check-release-token.mjs new file mode 100644 index 0000000..25bff37 --- /dev/null +++ b/scripts/check-release-token.mjs @@ -0,0 +1,16 @@ +#!/usr/bin/env node +// release-it `before:init` hook. Without GITHUB_TOKEN, release-it falls back +// to a web-based GitHub Release: it opens a prefilled form in the browser and +// reports success, but no release exists until someone submits that form. +// With no release, publish.yml never runs. Fail before anything changes. +// release-it skips hooks in --dry-run, so dry runs don't need a token. +if (!process.env.GITHUB_TOKEN) { + console.error( + [ + "GITHUB_TOKEN is not set.", + "release-it needs it to create the GitHub Release, which triggers publishing.", + "Run the Release workflow in GitHub Actions, or set GITHUB_TOKEN to a token with repo scope.", + ].join("\n"), + ); + process.exit(1); +}