diff --git a/.github/workflows/codex-update.yml b/.github/workflows/codex-update.yml index 9632f479..b96182d8 100644 --- a/.github/workflows/codex-update.yml +++ b/.github/workflows/codex-update.yml @@ -79,7 +79,7 @@ jobs: npm install "$CODEX_PACKAGE@$VERSION" npm run generate-types git add package.json package-lock.json src/app-server - git commit -m "Update codex to $VERSION" + git commit -m "fix: update codex to $VERSION" - name: Finalize update with Codex id: codex-finalize @@ -112,7 +112,7 @@ jobs: gh pr create \ --base main \ - --title "Update codex to $VERSION" \ + --title "fix: update codex to $VERSION" \ --body-file pr-body.md \ --label "$CODEX_UPDATE_LABEL" diff --git a/.github/workflows/conventional-prs.yml b/.github/workflows/conventional-prs.yml new file mode 100644 index 00000000..7a5671cb --- /dev/null +++ b/.github/workflows/conventional-prs.yml @@ -0,0 +1,48 @@ +name: Conventional PRs + +# Squash merges use the PR title as the commit subject, so the title is what +# ends up in the history release notes are generated from. +on: + pull_request: + branches: [main] + # labeled/unlabeled so validation re-runs when `dependencies` is applied. + types: [opened, reopened, edited, synchronize, labeled, unlabeled] + +permissions: {} + +concurrency: + group: "conventional-prs-${{ github.event.pull_request.number }}" + cancel-in-progress: true + +jobs: + validate: + name: Validate PR title + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + pull-requests: read + + steps: + # Config reference: https://github.com/amannn/action-semantic-pull-request#configuration + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Exactly the types release-please understands. feat/fix/perf/revert/docs + # appear in the changelog; the rest are valid but hidden. Any other type + # would be dropped silently, so it is rejected here instead. + types: | + feat + fix + perf + revert + docs + style + chore + refactor + test + build + ci + # Dependabot titles itself conventionally already; don't block bot PRs. + ignoreLabels: | + dependencies diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6cf4dae5..bb840fdc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,23 +1,82 @@ -name: Release +name: Publish and Release on: push: - tags: - - 'v*' + branches: + - main + # Recovery path for a release whose tag exists but whose npm publish or + # registry update did not land. See docs/RELEASES.md. + workflow_dispatch: + inputs: + ref: + description: Tag or commit to publish + required: true + type: string + publish_npm: + description: Publish the package before updating the registry + required: true + default: true + type: boolean permissions: {} jobs: - verify: + # Every push to main re-computes the next version from the conventional commit + # messages and keeps a single release PR open. Merging that PR is what tags, + # releases and publishes; there is no manual release button. + release-please: + if: ${{ github.event_name == 'push' }} runs-on: ubuntu-latest environment: release # Optional: for enhanced security + permissions: + contents: write + issues: write + pull-requests: write + steps: + # Generating a GitHub token, so that PRs and tags created by + # the release-please-action can trigger actions workflows. + - name: Generate GitHub token + uses: actions/create-github-app-token@v3 + id: generate-token + with: + app-id: ${{ secrets.RELEASE_PLZ_APP_ID }} + private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} + + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + id: release + with: + token: ${{ steps.generate-token.outputs.token }} + # Do not add release-type here. The action ignores config-file whenever + # release-type is set, which would silently drop + # include-component-in-tag and start tagging codex-acp-vX.Y.Z instead of + # vX.Y.Z. The node release type is declared in that config instead, so + # nothing is lost by leaving this out. + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + + # The gate for both paths: nothing is published unless this passes. Its `if` + # rather than `needs` alone decides whether a release is happening, because on + # workflow_dispatch the release-please job is skipped. + # + # Deliberately not in the `release` environment: OPENAI_API_KEY is a repository + # secret, so this needs nothing from it, and staying out keeps the job off the + # environment's deployment branch policy. + verify: + needs: [release-please] + if: ${{ always() && ((github.event_name == 'workflow_dispatch' && inputs.publish_npm) || needs.release-please.outputs.release_created == 'true') }} + runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }} - uses: actions/setup-node@v7 with: - node-version: '24' + node-version: "24" - name: Configure sandboxing run: | sudo apt-get update @@ -34,43 +93,31 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} run: npm run test:e2e - publish-to-npm: - needs: verify + publish-npm: + name: Publish to npm + needs: [release-please, verify] + if: ${{ always() && needs.verify.result == 'success' }} runs-on: ubuntu-latest environment: release # Optional: for enhanced security permissions: contents: read - id-token: write + id-token: write # npm trusted publishing, so there is no npm token steps: - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }} + # Setup .npmrc file to publish to npm - uses: actions/setup-node@v7 with: - node-version: '24' - registry-url: 'https://registry.npmjs.org' + node-version: "24" + registry-url: "https://registry.npmjs.org" - run: npm ci + # prepublishOnly builds the bundle. - run: npm publish --access public - create-release: - needs: publish-to-npm - runs-on: ubuntu-latest - environment: release - permissions: {} - steps: - - name: Generate GitHub token - uses: actions/create-github-app-token@v3 - id: generate-token - with: - app-id: ${{ secrets.RELEASE_PLZ_APP_ID }} - private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} - - - name: Create Release - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 - with: - token: ${{ steps.generate-token.outputs.token }} - generate_release_notes: true - trigger-registry-update: - needs: create-release + needs: publish-npm + if: ${{ always() && (needs.publish-npm.result == 'success' || (github.event_name == 'workflow_dispatch' && !inputs.publish_npm)) }} runs-on: ubuntu-latest environment: release permissions: {} diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml deleted file mode 100644 index 2aeeeb9c..00000000 --- a/.github/workflows/version-bump.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Version Bump - -on: - workflow_dispatch: - inputs: - bump: - description: 'Bump x.y.z (triggers release): patch=z, minor=y (resets z), major=x (resets y, z)' - type: choice - default: 'patch' - options: - - patch - - minor - - major - -permissions: {} - -jobs: - bump: - runs-on: ubuntu-latest - steps: - - name: Generate GitHub token - uses: actions/create-github-app-token@v3 - id: generate-token - with: - app-id: ${{ secrets.RELEASE_PLZ_APP_ID }} - private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} - - - uses: actions/checkout@v7 - with: - token: ${{ steps.generate-token.outputs.token }} - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # Bumps the version, commits to main, and pushes a v* tag. - # The tag push triggers the Release workflow (publish.yml), which publishes to npm. - - name: Bump version - run: npm version ${{ inputs.bump }} -m "Release v%s" - - - name: Push commit and tag - run: git push --follow-tags origin main diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 00000000..011ac868 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "1.1.14" +} diff --git a/AGENTS.md b/AGENTS.md index 960f4ad4..31c96fd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,18 @@ - Focus on behavior and outputs rather than implementation details. - Use `/run-codex` skill (`.claude/skills/run-codex/`) to test with real Codex and observe actual events. +## Pull Requests + +- Squash merges use the PR title as the commit subject, and release-please parses it to compute the next version. Titles must be conventional commits using one of: `feat`, `fix`, `perf`, `revert`, `docs`, `style`, `chore`, `refactor`, `test`, `build`, `ci`. `conventional-prs.yml` rejects anything else. +- The title also decides the release: `feat:` bumps the minor, `fix:`/`perf:`/`revert:` the patch, a `!` bumps the major, and `chore:`/`ci:`/`docs:` and friends do not release at all. + +## Releasing + +- Releases are fully automated by release-please. There is no manual release workflow, and the version is never chosen by hand — it follows from the commit history. +- `npm run release:preflight` verifies it is safe to release and prints the PR number and version; then `gh pr merge --squash`. +- The preflight is the guard-list as code; if it exits non-zero, follow what it prints rather than merging. +- Full runbook, including how to recover a stalled release: [`docs/RELEASES.md`](docs/RELEASES.md). + ## Docs - Codex app-server usage: see https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md when touching protocol/transport details, adding or consuming JSON-RPC methods, handling approvals/turn events, or updating generated schema/clients. diff --git a/docs/RELEASES.md b/docs/RELEASES.md new file mode 100644 index 00000000..0eef4ff5 --- /dev/null +++ b/docs/RELEASES.md @@ -0,0 +1,141 @@ +# Releases + +Releases are automated by [release-please](https://github.com/googleapis/release-please) +in [`.github/workflows/publish.yml`](../.github/workflows/publish.yml). Every push +to `main` re-computes the next version from the conventional commit messages and +keeps a single release PR open, titled `chore(main): release X.Y.Z` and labelled +`autorelease: pending`. + +Merging that PR is what releases. It tags `vX.Y.Z`, creates the GitHub release, +runs the verification suite, publishes to npm, and dispatches a version update to +the agent registry. + +There is no manual release button, and versions are never typed in by hand: the +version is an output of the commit history, not an input. + +## Releasing + +```sh +npm run release:preflight +``` + +This reports the open release PR, the version it will ship, and checks that the +repository is in a state where merging is safe. Nothing has to be remembered — +if it exits non-zero, follow what it prints instead of merging. + +Then merge it, using the PR number the preflight printed: + +```sh +gh pr merge --squash +gh run watch "$(gh run list --workflow=publish.yml --limit 1 --json databaseId --jq '.[0].databaseId')" +``` + +The run is looked up rather than picked interactively, so this is safe to script. +If the workflow has already finished, `gh run list --workflow=publish.yml` shows +the outcome instead. + +Merging main requires no review, so a green preflight and `ci` are the only gates +before the merge. After it, the `verify` job re-runs typecheck, unit tests and the +e2e suite against the release commit, and nothing is published unless it passes. +Once the workflow finishes, confirm both outputs landed: + +```sh +gh release view "v" +npm view "@agentclientprotocol/codex-acp@" +``` + +## How the version is chosen + +Squash merges use the PR title as the commit subject, so the PR title decides the +next version. [`conventional-prs.yml`](../.github/workflows/conventional-prs.yml) +rejects titles release-please would not understand. + +| PR title prefix | Effect | +| ------------------------------------------------------------------- | --------------------------- | +| `fix:`, `perf:`, `revert:` | patch, e.g. 1.1.14 → 1.1.15 | +| `feat:` | minor, e.g. 1.1.14 → 1.2.0 | +| any of the above with `!`, or BREAKING CHANGE | major, e.g. 1.1.14 → 2.0.0 | +| `docs:`, `style:`, `chore:`, `refactor:`, `test:`, `build:`, `ci:` | no release on their own | + +The last row is a property of release-please's default changelog sections: those +types are hidden, so when nothing else has landed since the last tag the release +notes come out empty and no release PR is opened at all. They still ride along in +the next release a `feat:` or `fix:` triggers; they just do not appear in the +changelog. + +The scheduled Codex bumps opened by +[`codex-update.yml`](../.github/workflows/codex-update.yml) title themselves +`fix:` on purpose. They run close to daily, and titling them `feat:` would walk +the minor version every time a dependency moved. + +The package is past 1.0.0, so a `!` really does ship a major version — unlike +pre-1.0 repositories, there is no `bump-minor-pre-major` safety net to fall back +on (setting it would have no effect above 1.0.0). Treat `!` in a PR title as an +explicit decision to release a major. + +Note that `config-file` only takes effect while the workflow does **not** pass a +`release-type` input to the action — with `release-type` set, the action ignores +the config entirely. The release type is declared inside the config instead. + +Because the config is what is read, it also has to say +`"include-component-in-tag": false`. Left at its default, release-please derives a +component from the package name and tags `codex-acp-vX.Y.Z` instead of `vX.Y.Z`. +That renames the tag every step here looks up, and because no tag under the new +scheme exists, it also walks the entire commit history into the changelog rather +than just what landed since the last release. The preflight checks the tag +release-please is going to use, so this cannot reach a published release. + +If a specific version has to be forced, add `"release-as": "X.Y.Z"` to +`release-please-config.json` in its own PR, release, then remove it again. + +## Recovering a stalled release + +### The release PR merged but nothing was tagged + +The preflight fails with `release-please is jammed`. While a merged release PR +still carries `autorelease: pending`, release-please refuses to open any new +release PR at all, so every later release stalls silently until this is cleared. + +Take the release notes release-please already wrote into the changelog, create +the missing release, then move the label the way release-please would have: + +```sh +awk '/^## \[\]/{f=1;print;next} /^## \[/{f=0} f' CHANGELOG.md > notes.md +gh release create "v" --target --notes-file notes.md +gh pr edit --remove-label "autorelease: pending" \ + --add-label "autorelease: tagged" +``` + +Then publish the tag as described below. + +### The tag exists but npm or the registry is missing + +npm publishes through OIDC from inside the workflow, so this cannot be done from +a laptop. Re-run the publish workflow against the existing tag: + +```sh +gh workflow run publish.yml -f ref="v" -f publish_npm=true +``` + +This re-runs `verify` against that ref before publishing, so a flaky e2e run will +block it; re-run the workflow rather than working around it. + +npm versions are immutable. If the package already published and only the +registry update failed, pass `-f publish_npm=false` so the run skips verification +and publishing and only re-dispatches the registry update. + +## Credentials and repository settings + +| Secret | Used for | +| ------------------------------------------------------------- | ----------------------------------------------------------------- | +| `RELEASE_PLZ_APP_ID`, `RELEASE_PLZ_APP_PRIVATE_KEY` | App token for release PRs and tags, so they can trigger workflows | +| `REGISTRY_UPDATER_APP_ID`, `REGISTRY_UPDATER_APP_PRIVATE_KEY` | App token scoped to the `registry` repository | +| `OPENAI_API_KEY` | The e2e suite in the `verify` job | + +Publishing to npm uses OIDC trusted publishing, so there is no npm token. The +release-please, publish and registry jobs run in the `release` environment. + +Because those jobs are now triggered by pushes to `main` rather than by a `v*` +tag, the `release` environment's deployment branch policy has to allow the `main` +branch in addition to `v*` tags. Without it every release job fails before it +starts with a branch-not-allowed error. diff --git a/package.json b/package.json index a10abb7a..01f6fe05 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "example:steering": "node --import tsx examples/steering.ts", "example:steering:multistep": "node --import tsx examples/steering.ts", "generate-types": "./node_modules/.bin/codex app-server generate-ts --out src/app-server", + "release:preflight": "bash scripts/release-preflight.sh", "test": "vitest run", "test:e2e": "npm run build && RUN_E2E_TESTS=true vitest run src/__tests__/CodexACPAgent/e2e", "test:watch": "vitest", diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..fafefe01 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,11 @@ +{ + "include-component-in-tag": false, + "packages": { + ".": { + "changelog-path": "CHANGELOG.md", + "release-type": "node", + "include-component-in-tag": false + } + }, + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" +} diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh new file mode 100755 index 00000000..762c5110 --- /dev/null +++ b/scripts/release-preflight.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# +# Preflight checks for a release. +# +# release-please decides what the next version is; this script only verifies the +# repository is in a state where merging the open release PR produces a correct +# release. Merging main needs no review, so these checks are the only thing +# standing between a mistake and a published package — keep them as code rather +# than as a list someone has to remember. See docs/RELEASES.md. +# +# Requires an authenticated GitHub CLI. Nothing else: gh has a jq engine builtin. + +set -euo pipefail + +PENDING_LABEL="autorelease: pending" +# The check that must be green on the release PR. This is the job id in +# ci.yml; e2e tests do not run on pull requests, so they are not checked here. +REQUIRED_CHECK="ci" + +fail() { + printf 'FAIL %s\n' "$1" >&2 + exit 1 +} + +pass() { + printf 'ok %s\n' "$1" +} + +command -v gh >/dev/null 2>&1 || + fail "GitHub CLI (gh) is not installed." +gh auth status >/dev/null 2>&1 || + fail "GitHub CLI is not authenticated. Run 'gh auth login'." + +repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) || + fail "cannot work out which GitHub repository this is. Run from a checkout." + +# A merged release PR that still carries the pending label means release-please +# never tagged it. In that state it refuses to open any new release PR at all, +# so every later release stalls silently until it is recovered by hand. +stuck=$(gh pr list --repo "$repo" --state merged --label "$PENDING_LABEL" \ + --json number --jq 'map("#\(.number)") | join(", ")') +[ -z "$stuck" ] || + fail "merged release PR still labelled '$PENDING_LABEL': $stuck. + release-please is jammed and will not open new release PRs. + See docs/RELEASES.md, 'Recovering a stalled release'." +pass "no untagged merged release PR" + +rows=$(gh pr list --repo "$repo" --state open --label "$PENDING_LABEL" \ + --json number,title,headRefName --jq '.[] | [.number, .headRefName, .title] | @tsv') +count=$(printf '%s\n' "$rows" | grep -c . || true) + +if [ "$count" -eq 0 ]; then + fail "no open release PR. + Nothing has landed since the last tag that release-please puts in a + changelog, so there is nothing to release. Land a feat/fix/perf/revert + change first, or check that the Publish and Release workflow is running." +fi +if [ "$count" -gt 1 ]; then + fail "$count open release PRs, expected exactly one: +$rows" +fi +IFS=$(printf '\t') read -r pr_number head_ref pr_title < X.Y.Z' whenever it +# is configured to tag with one. +title_version=${pr_title##* } +case $title_version in +[0-9]*.[0-9]*.[0-9]*) ;; +*) fail "cannot read a version from PR title '$pr_title'." ;; +esac +pkg_version=$(gh api "repos/$repo/contents/package.json?ref=$head_ref" \ + -H "Accept: application/vnd.github.raw" --jq .version) +manifest_version=$(gh api "repos/$repo/contents/.release-please-manifest.json?ref=$head_ref" \ + -H "Accept: application/vnd.github.raw" --jq '."."') + +if [ "$title_version" != "$pkg_version" ] || [ "$title_version" != "$manifest_version" ]; then + fail "version mismatch on #$pr_number: + PR title $title_version + package.json $pkg_version + .release-please-manifest.json $manifest_version" +fi +pass "version agrees across PR title, package.json and manifest: $title_version" + +# The tag comes from the config, not from the PR title. With +# include-component-in-tag left on, release-please tags -vX.Y.Z, which +# breaks the vX.Y.Z scheme the rest of this script and docs/RELEASES.md rely on, +# and makes it rewrite the whole history into the changelog because no tag under +# that scheme exists yet. The compare link it writes into the PR body names the +# tags it is going to use, so check those rather than trusting the config. +compare_tag=$(gh pr view "$pr_number" --repo "$repo" --json body --jq ' + .body | capture("/compare/[^)]*[.][.][.](?[^)\\s]+)").to // ""' 2>/dev/null || true) +case $compare_tag in +"" | "v$title_version") ;; +*) fail "release-please is going to tag '$compare_tag', not 'v$title_version'. + Every previous release is tagged vX.Y.Z, and the changelog for #$pr_number + covers the whole history rather than just this release, because no tag + under that scheme exists. Set 'include-component-in-tag': false in + release-please-config.json, then let it rewrite the release PR." ;; +esac + +if gh release view "v$title_version" --repo "$repo" >/dev/null 2>&1; then + fail "tag v$title_version already exists. Merging would try to release it twice." +fi +pass "tag v$title_version does not exist yet" + +build=$(gh pr view "$pr_number" --repo "$repo" --json statusCheckRollup --jq " + [.statusCheckRollup[]? | select(.name == \"$REQUIRED_CHECK\")] + | if length == 0 then \"MISSING\" else (.[0].conclusion // .[0].status // \"PENDING\") end") +[ "$build" = "SUCCESS" ] || + fail "required check '$REQUIRED_CHECK' is $build on #$pr_number, expected SUCCESS." +pass "required check '$REQUIRED_CHECK' passed" + +cat <