diff --git a/.changeset/promotion-bot-pr.md b/.changeset/promotion-bot-pr.md new file mode 100644 index 000000000..8997a55a3 --- /dev/null +++ b/.changeset/promotion-bot-pr.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-cli": patch +"@bradygaster/squad-sdk": patch +--- + +Add a protected-main promotion mode that validates an existing sanitized release branch and opens a bot-authored pull request for independent maintainer approval. diff --git a/.copilot/skills/release-process/SKILL.md b/.copilot/skills/release-process/SKILL.md index 1119f778a..629fcf64c 100644 --- a/.copilot/skills/release-process/SKILL.md +++ b/.copilot/skills/release-process/SKILL.md @@ -15,7 +15,7 @@ recovery commands. The operational model is: |---------|---------|------------------|--------| | Insider | Manual `squad-insider-publish.yml` dispatch from `dev` | Generated `X.Y.Z-insider.N` | GitHub prerelease, npm `insider`, standalone archives, Homebrew, WinGet | | Preview | Manual `squad-release.yml` dispatch from `dev` | `X.Y.Z-preview.N` | GitHub prerelease, npm `preview`, standalone archives, Homebrew, WinGet | -| Stable | Manual `squad-promote.yml` dispatch from `dev` | `X.Y.Z` | Sanitized `main` push, GitHub stable release, npm `latest`, standalone archives, Homebrew, WinGet | +| Stable | Manual `squad-promote.yml` dispatch from `dev` | `X.Y.Z` | Sanitized promotion PR or legacy `main` push, then GitHub stable release, npm `latest`, standalone archives, Homebrew, WinGet | There is no staging `preview` branch. Do not create tags or GitHub Releases manually. @@ -102,11 +102,28 @@ gh workflow run squad-promote.yml --ref dev -f dry_run=false gh run watch ``` -Promotion merges `dev` directly into `main`, strips internal team state, -validates the release tree, builds it, runs tests, and pushes `main`. It then -explicitly dispatches `squad-release.yml` because `GITHUB_TOKEN` pushes do not -start another workflow. The release creates the tag and stable GitHub Release -and directly invokes npm and standalone publication. +When a validated sanitized promotion branch already exists in the upstream +repository, a human may instead have `github-actions[bot]` open the protected +`main` PR: + +```bash +gh workflow run squad-promote.yml --ref dev \ + -f dry_run=false \ + -f promotion_branch=chore/promote-X.Y.Z +``` + +The workflow revalidates the branch, builds it, runs the release-gate tests, +and opens the PR with `GITHUB_TOKEN`. GitHub may hold the resulting +`pull_request` workflow runs for maintainer approval; a maintainer with write +access must approve those runs, approve the PR, and merge it. The human merge +pushes `main`, which starts `squad-release.yml` normally. + +Without `promotion_branch`, the legacy path merges `dev` directly into `main`, +strips internal team state, validates the release tree, builds it, runs tests, +and pushes `main`. It then explicitly dispatches `squad-release.yml` because +`GITHUB_TOKEN` pushes do not start another workflow. The release creates the +tag and stable GitHub Release and directly invokes npm and standalone +publication. ## Verify diff --git a/.github/workflows/squad-promote.yml b/.github/workflows/squad-promote.yml index 0f13a50e8..1351ad8a4 100644 --- a/.github/workflows/squad-promote.yml +++ b/.github/workflows/squad-promote.yml @@ -9,6 +9,11 @@ on: default: 'false' type: choice options: ['false', 'true'] + promotion_branch: + description: 'Existing validated in-repository promotion branch to open as a bot-authored PR' + required: false + default: '' + type: string concurrency: group: ${{ github.workflow }} @@ -19,7 +24,156 @@ permissions: contents: write jobs: + open-existing-promotion: + if: ${{ inputs.promotion_branch != '' }} + name: Open validated promotion PR + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Validate promotion branch input + env: + BRANCH: ${{ inputs.promotion_branch }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [[ ! "${BRANCH}" =~ ^chore/promote(-v)?-[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Promotion branch must match chore/promote-X.Y.Z or chore/promote-vX.Y.Z." + exit 1 + fi + gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${BRANCH}" --silent + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7 + with: + fetch-depth: 0 + ref: ${{ inputs.promotion_branch }} + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 #v6 + with: + node-version: 22 + cache: 'npm' + + - name: Validate existing promotion tree + id: validate + run: | + set -euo pipefail + git fetch origin '+refs/heads/main:refs/remotes/origin/main' + git merge-base --is-ancestor origin/main HEAD || { + echo "::error::Promotion branch must descend from the current main branch." + exit 1 + } + + forbidden="$(git ls-files | grep -E '^(\.(ai-team|squad|ai-team-templates)/|team-docs/|docs/proposals/)' || true)" + if [ -n "${forbidden}" ]; then + echo "::error::Forbidden internal files remain in the promotion branch:" + printf '%s\n' "${forbidden}" + exit 1 + fi + + changesets="$(git ls-files '.changeset/*.md' | grep -v '^.changeset/README.md$' || true)" + if [ -n "${changesets}" ]; then + echo "::error::Consumed changesets remain in the promotion branch:" + printf '%s\n' "${changesets}" + exit 1 + fi + + version="$(node -p 'require("./package.json").version')" + stable_re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' + if [[ ! "${version}" =~ ${stable_re} ]]; then + echo "::error::Stable promotion requires a MAJOR.MINOR.PATCH version, got '${version}'." + exit 1 + fi + + for manifest in packages/squad-sdk/package.json packages/squad-cli/package.json; do + package_version="$(node -p "require('./${manifest}').version")" + if [ "${package_version}" != "${version}" ]; then + echo "::error::${manifest} version '${package_version}' does not match root version '${version}'." + exit 1 + fi + done + + lock_root="$(node -p 'require("./package-lock.json").version')" + lock_sdk="$(node -p 'require("./package-lock.json").packages["packages/squad-sdk"].version')" + lock_cli="$(node -p 'require("./package-lock.json").packages["packages/squad-cli"].version')" + if [ "${lock_root}" != "${version}" ] || [ "${lock_sdk}" != "${version}" ] || [ "${lock_cli}" != "${version}" ]; then + echo "::error::package-lock.json package versions must all match '${version}'." + exit 1 + fi + + expected_sdk_dependency=">=${version}" + sdk_dependency="$(node -p "require('./packages/squad-cli/package.json').dependencies['@bradygaster/squad-sdk']")" + lock_dependency="$(node -p "require('./package-lock.json').packages['packages/squad-cli'].dependencies['@bradygaster/squad-sdk']")" + if [ "${sdk_dependency}" != "${expected_sdk_dependency}" ] || [ "${lock_dependency}" != "${expected_sdk_dependency}" ]; then + echo "::error::CLI SDK dependency floors must both be '${expected_sdk_dependency}'." + exit 1 + fi + + grep -Fq "## [${version}]" CHANGELOG.md || { + echo "::error::Version ${version} is missing from CHANGELOG.md." + exit 1 + } + + test -z "$(git status --porcelain)" || { + echo "::error::Promotion branch checkout is not clean." + git status --short + exit 1 + } + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Install dependencies + run: npm ci + + - name: Build release tree + env: + SKIP_BUILD_BUMP: '1' + run: npm run build + + - name: Run release tests + run: node --test test/*.test.cjs + + - name: Verify validation did not mutate the branch + run: | + test -z "$(git status --porcelain)" || { + echo "::error::Build or tests modified the promotion branch." + git status --short + exit 1 + } + + - name: Open bot-authored promotion PR + if: inputs.dry_run == 'false' + env: + BRANCH: ${{ inputs.promotion_branch }} + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.validate.outputs.version }} + run: | + set -euo pipefail + existing_url="$(gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --state open \ + --base main \ + --head "${BRANCH}" \ + --json url \ + --jq '.[0].url // ""')" + if [ -n "${existing_url}" ]; then + echo "Promotion PR already exists: ${existing_url}" + exit 0 + fi + + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base main \ + --head "${BRANCH}" \ + --title "chore(release): promote dev to main (v${VERSION})" \ + --body "Bot-authored promotion PR for validated Squad v${VERSION} release branch \`${BRANCH}\`. + + Validation run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} + + The repository's required pull-request checks may initially wait for a maintainer to approve workflow execution because this PR was created with \`GITHUB_TOKEN\`." + dev-to-main: + if: ${{ inputs.promotion_branch == '' }} name: Promote sanitized dev to main runs-on: ubuntu-latest steps: diff --git a/.squad-templates/skills/release-process/SKILL.md b/.squad-templates/skills/release-process/SKILL.md index 68818d6d4..10172b6fd 100644 --- a/.squad-templates/skills/release-process/SKILL.md +++ b/.squad-templates/skills/release-process/SKILL.md @@ -15,7 +15,7 @@ and two long-lived branches: |--------|---------|--------|-----|------------|-----------------| | Insider dispatch from `dev` | Generated `X.Y.Z-insider.N` | Prerelease | `insider` | Yes | Yes | | Release dispatch from `dev` | `X.Y.Z-preview.N` | Prerelease | `preview` | Yes | Yes | -| Push to `main` by promotion workflow | `X.Y.Z` | Stable/latest | `latest` | Yes | Yes | +| Promotion PR merged to `main` (or legacy workflow push) | `X.Y.Z` | Stable/latest | `latest` | Yes | Yes | The repository does not use a staging `preview` branch. Preview is a release channel, not a branch. @@ -172,6 +172,24 @@ gh workflow run squad-promote.yml --ref dev -f dry_run=false gh run watch ``` +When a validated sanitized promotion branch already exists in the upstream +repository, a human can instead have `github-actions[bot]` open the protected +`main` PR: + +```bash +gh workflow run squad-promote.yml --ref dev \ + -f dry_run=false \ + -f promotion_branch=chore/promote-X.Y.Z +``` + +The workflow revalidates the branch, builds it, runs the release-gate tests, +and opens the PR with `GITHUB_TOKEN`. GitHub may hold the resulting +`pull_request` workflow runs for maintainer approval. A maintainer with write +access must approve those runs, approve the PR, and merge it. The merge push +to `main` starts `squad-release.yml` normally. + +Without `promotion_branch`, the legacy workflow: + `squad-promote.yml`: 1. Merges `origin/dev` directly into `main`. diff --git a/packages/squad-cli/templates/skills/release-process/SKILL.md b/packages/squad-cli/templates/skills/release-process/SKILL.md index 68818d6d4..10172b6fd 100644 --- a/packages/squad-cli/templates/skills/release-process/SKILL.md +++ b/packages/squad-cli/templates/skills/release-process/SKILL.md @@ -15,7 +15,7 @@ and two long-lived branches: |--------|---------|--------|-----|------------|-----------------| | Insider dispatch from `dev` | Generated `X.Y.Z-insider.N` | Prerelease | `insider` | Yes | Yes | | Release dispatch from `dev` | `X.Y.Z-preview.N` | Prerelease | `preview` | Yes | Yes | -| Push to `main` by promotion workflow | `X.Y.Z` | Stable/latest | `latest` | Yes | Yes | +| Promotion PR merged to `main` (or legacy workflow push) | `X.Y.Z` | Stable/latest | `latest` | Yes | Yes | The repository does not use a staging `preview` branch. Preview is a release channel, not a branch. @@ -172,6 +172,24 @@ gh workflow run squad-promote.yml --ref dev -f dry_run=false gh run watch ``` +When a validated sanitized promotion branch already exists in the upstream +repository, a human can instead have `github-actions[bot]` open the protected +`main` PR: + +```bash +gh workflow run squad-promote.yml --ref dev \ + -f dry_run=false \ + -f promotion_branch=chore/promote-X.Y.Z +``` + +The workflow revalidates the branch, builds it, runs the release-gate tests, +and opens the PR with `GITHUB_TOKEN`. GitHub may hold the resulting +`pull_request` workflow runs for maintainer approval. A maintainer with write +access must approve those runs, approve the PR, and merge it. The merge push +to `main` starts `squad-release.yml` normally. + +Without `promotion_branch`, the legacy workflow: + `squad-promote.yml`: 1. Merges `origin/dev` directly into `main`. diff --git a/packages/squad-sdk/templates/skills/release-process/SKILL.md b/packages/squad-sdk/templates/skills/release-process/SKILL.md index 68818d6d4..10172b6fd 100644 --- a/packages/squad-sdk/templates/skills/release-process/SKILL.md +++ b/packages/squad-sdk/templates/skills/release-process/SKILL.md @@ -15,7 +15,7 @@ and two long-lived branches: |--------|---------|--------|-----|------------|-----------------| | Insider dispatch from `dev` | Generated `X.Y.Z-insider.N` | Prerelease | `insider` | Yes | Yes | | Release dispatch from `dev` | `X.Y.Z-preview.N` | Prerelease | `preview` | Yes | Yes | -| Push to `main` by promotion workflow | `X.Y.Z` | Stable/latest | `latest` | Yes | Yes | +| Promotion PR merged to `main` (or legacy workflow push) | `X.Y.Z` | Stable/latest | `latest` | Yes | Yes | The repository does not use a staging `preview` branch. Preview is a release channel, not a branch. @@ -172,6 +172,24 @@ gh workflow run squad-promote.yml --ref dev -f dry_run=false gh run watch ``` +When a validated sanitized promotion branch already exists in the upstream +repository, a human can instead have `github-actions[bot]` open the protected +`main` PR: + +```bash +gh workflow run squad-promote.yml --ref dev \ + -f dry_run=false \ + -f promotion_branch=chore/promote-X.Y.Z +``` + +The workflow revalidates the branch, builds it, runs the release-gate tests, +and opens the PR with `GITHUB_TOKEN`. GitHub may hold the resulting +`pull_request` workflow runs for maintainer approval. A maintainer with write +access must approve those runs, approve the PR, and merge it. The merge push +to `main` starts `squad-release.yml` normally. + +Without `promotion_branch`, the legacy workflow: + `squad-promote.yml`: 1. Merges `origin/dev` directly into `main`. diff --git a/templates/skills/release-process/SKILL.md b/templates/skills/release-process/SKILL.md index 68818d6d4..10172b6fd 100644 --- a/templates/skills/release-process/SKILL.md +++ b/templates/skills/release-process/SKILL.md @@ -15,7 +15,7 @@ and two long-lived branches: |--------|---------|--------|-----|------------|-----------------| | Insider dispatch from `dev` | Generated `X.Y.Z-insider.N` | Prerelease | `insider` | Yes | Yes | | Release dispatch from `dev` | `X.Y.Z-preview.N` | Prerelease | `preview` | Yes | Yes | -| Push to `main` by promotion workflow | `X.Y.Z` | Stable/latest | `latest` | Yes | Yes | +| Promotion PR merged to `main` (or legacy workflow push) | `X.Y.Z` | Stable/latest | `latest` | Yes | Yes | The repository does not use a staging `preview` branch. Preview is a release channel, not a branch. @@ -172,6 +172,24 @@ gh workflow run squad-promote.yml --ref dev -f dry_run=false gh run watch ``` +When a validated sanitized promotion branch already exists in the upstream +repository, a human can instead have `github-actions[bot]` open the protected +`main` PR: + +```bash +gh workflow run squad-promote.yml --ref dev \ + -f dry_run=false \ + -f promotion_branch=chore/promote-X.Y.Z +``` + +The workflow revalidates the branch, builds it, runs the release-gate tests, +and opens the PR with `GITHUB_TOKEN`. GitHub may hold the resulting +`pull_request` workflow runs for maintainer approval. A maintainer with write +access must approve those runs, approve the PR, and merge it. The merge push +to `main` starts `squad-release.yml` normally. + +Without `promotion_branch`, the legacy workflow: + `squad-promote.yml`: 1. Merges `origin/dev` directly into `main`. diff --git a/test/standalone-release-workflow.test.ts b/test/standalone-release-workflow.test.ts index db0a2e430..98fdb6d15 100644 --- a/test/standalone-release-workflow.test.ts +++ b/test/standalone-release-workflow.test.ts @@ -233,7 +233,7 @@ describe('standalone release handoff', () => { describe('stable promotion', () => { it('promotes directly from dev to main through a validated sanitized merge', () => { expect(existsSync(join(WORKFLOWS, 'squad-preview.yml'))).toBe(false); - expect(Object.keys(promoteWorkflow.jobs)).toEqual(['dev-to-main']); + expect(Object.keys(promoteWorkflow.jobs)).toEqual(['open-existing-promotion', 'dev-to-main']); const promotion = serialized(promoteWorkflow.jobs['dev-to-main']); expect(promotion).toContain( @@ -261,6 +261,39 @@ describe('stable promotion', () => { contents: 'write', }); }); + + it('can open a validated in-repository promotion branch as a bot-authored PR', () => { + expect(promoteWorkflow.on?.workflow_dispatch?.inputs?.promotion_branch).toMatchObject({ + required: false, + default: '', + type: 'string', + }); + + const botPromotion = promoteWorkflow.jobs['open-existing-promotion']; + expect(botPromotion.if).toBe("${{ inputs.promotion_branch != '' }}"); + expect(botPromotion.permissions).toEqual({ + contents: 'read', + 'pull-requests': 'write', + }); + expect(promoteWorkflow.jobs['dev-to-main'].if).toBe("${{ inputs.promotion_branch == '' }}"); + + const promotion = serialized(botPromotion); + expect(promotion).toContain( + 'Promotion branch must match chore/promote-X.Y.Z or chore/promote-vX.Y.Z', + ); + expect(promotion).toContain('git merge-base --is-ancestor origin/main HEAD'); + expect(promotion).toContain("git ls-files '.changeset/*.md'"); + expect(promotion).toContain('CLI SDK dependency floors must both be'); + expect(promotion).toContain('SKIP_BUILD_BUMP'); + expect(promotion).toContain('node --test test/*.test.cjs'); + expect(promotion).toContain('Build or tests modified the promotion branch'); + expect(promotion).toContain('gh pr create'); + expect(promotion).toContain('--base main'); + expect(promotion).toContain('--head'); + expect(promotion).toContain('${BRANCH}'); + expect(promotion).toContain('created with'); + expect(promotion).toContain('GITHUB_TOKEN'); + }); }); describe('reusable npm publication', () => {