Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
82 changes: 82 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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[@]}"
1 change: 1 addition & 0 deletions .release-it.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"publish": false
},
"hooks": {
"before:init": "node scripts/check-release-token.mjs",
"before:git:beforeRelease": "npm run build"
},
"plugins": {
Expand Down
80 changes: 53 additions & 27 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand All @@ -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-<target>`
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
```
16 changes: 16 additions & 0 deletions scripts/check-release-token.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading