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); +}