diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdf2f2e..5ce6aca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,11 +111,29 @@ jobs: node-version: "22" - run: node --test scripts/*.test.mts + # The composite action itself, from this checkout, through the published merge-when: valid input reaches merge.sh, which finds no open pull request at this run's own commit (a merge ref or a commit on main) and stops. + action: + name: Action + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + checks: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./ + with: + # Never used: no open pull request has this run's own commit at its head. + merge-token: unused + head-sha: ${{ github.sha }} + required-check: Required Checks + # A single stable branch-protection target aggregating every job that runs on every pull request, so the required status check doesn't need updating in GitHub's branch protection settings whenever a job is added, renamed, or split -- only this job's own needs: list changes, in the same commit as whatever job it now covers. if: always() so this job still runs (and can fail) even when one of its dependencies failed outright, rather than being skipped itself; a dependency that never ran for this event (commitlint on a plain push to main) reports "skipped", not "failure" or "cancelled", so it doesn't block release below. required-checks: name: Required Checks if: always() - needs: [commitlint, actionlint, typecheck, lint, format, test] + needs: [commitlint, actionlint, typecheck, lint, format, test, action] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.github/workflows/merge-when-green.yml b/.github/workflows/merge-when-green.yml new file mode 100644 index 0000000..5ec0dbe --- /dev/null +++ b/.github/workflows/merge-when-green.yml @@ -0,0 +1,33 @@ +name: Merge when green + +# Runs merge-when-green beside this repository's own ready-when-green.yml, as its README suggests. A pull request labelled `automerge` is rebase-merged with the organisation's GitHub App once CI's "Required Checks" job has passed; a draft is left until ready-when-green.yml marks it ready, and the ready_for_review event that causes starts this workflow again. + +on: + workflow_run: + workflows: [CI] + types: [completed] + pull_request_target: + types: [labeled, ready_for_review] + +concurrency: + group: merge-when-green-${{ github.event.workflow_run.head_sha || github.event.pull_request.head.sha }} + cancel-in-progress: false + +permissions: + contents: read + pull-requests: read + checks: read + +jobs: + merge: + if: >- + (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success') + || (github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'automerge')) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: ExaDev/merge-when-green@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + app-private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + required-check: Required Checks diff --git a/.github/workflows/ready-when-green.yml b/.github/workflows/ready-when-green.yml new file mode 100644 index 0000000..ca28910 --- /dev/null +++ b/.github/workflows/ready-when-green.yml @@ -0,0 +1,30 @@ +name: Ready when green + +# This repository uses its own action, from the default branch's copy (workflow_run runs the default branch's workflows, and the checkout below is of that same branch, never of pull request code). A draft labelled `automerge` is marked ready for review with the organisation's GitHub App once CI's "Required Checks" job has passed; the ready_for_review event that starts then runs merge-when-green.yml, which merges it. + +on: + workflow_run: + workflows: [CI] + types: [completed] + +concurrency: + group: ready-when-green-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: false + +permissions: + contents: read + pull-requests: read + checks: read + +jobs: + ready: + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./ + with: + app-id: ${{ secrets.GH_APP_ID }} + app-private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + required-check: Required Checks diff --git a/README.md b/README.md index f83bd24..667adf6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,77 @@ # ready-when-green -Marks labelled draft pull requests ready for review once a required check has passed on their head commit. +Marks a labelled draft pull request ready for review once a named check has passed on its head commit. It is [`merge-when`](https://github.com/ExaDev/merge-when) with `then: ready` and the conditions fixed to "labelled and the named check green"; use `merge-when` directly to choose other conditions. + +## When to use this + +Open pull requests as drafts while CI is still deciding, and let them become reviewable, and mergeable, only once it is green. That suits agents and bots that open a pull request as soon as a branch exists, and anyone who would rather reviewers and merge automation never see a red pull request. Run it beside [`merge-when-green`](https://github.com/ExaDev/merge-when-green), which leaves drafts alone: this action marks the draft ready, the `ready_for_review` event that causes starts `merge-when-green` again, and it merges. + +A pull request is marked ready when all of these hold: it carries the opt-in label, it is a draft, it still has the commit being checked at its head, and the named check run succeeded on that commit. One that is already ready is left alone. + +## Use + +The action has no triggers of its own, so a workflow supplies them. Run it when CI finishes, in a workflow of its own beside the one that runs `merge-when-green`: + +```yaml +name: Ready when green + +on: + workflow_run: + workflows: [CI] + types: [completed] + +concurrency: + group: ready-when-green-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: false + +permissions: + contents: read + pull-requests: read + checks: read + +jobs: + ready: + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - uses: ExaDev/ready-when-green@v1 + with: + merge-token: ${{ secrets.MERGE_TOKEN }} + required-check: Required checks +``` + +The `merge-when-green` workflow needs `ready_for_review` among its `pull_request_target` types, as its own example already has, so that a pull request this action marks ready is merged at once: + +```yaml +on: + workflow_run: + workflows: [CI] + types: [completed] + pull_request_target: + types: [labeled, ready_for_review] +``` + +Three things make the pair work: + +- **CI has to run on drafts.** The required check must report on a draft's head commit, so don't skip CI jobs with `if: github.event.pull_request.draft == false` or start CI only on `ready_for_review`; GitHub's default `pull_request` types already include drafts. +- **The token must not be `GITHUB_TOKEN`.** GitHub starts no workflow from an event that token caused, so a pull request it marked ready would never start the merge. Give `merge-token`, or `app-id` and `app-private-key`; the action refuses to run without one. +- **The same label opts in to both.** With the default `automerge`, a draft labelled once is readied and then merged. Give a different `label` to have pull requests readied without being merged. + +Marking a pull request ready is not pinned to a commit the way the merge is, so a push landing between the check and the call is readied with it. A pull request readied wrongly goes back to draft with `gh pr ready --undo`. Do not add a checkout step to a workflow that runs on `pull_request_target`. + +## Inputs + +| Input | Default | Meaning | +| --------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `required-check` | required | Name of the check run that must have succeeded on the head commit. Point it at one aggregate job that `needs` every other job and runs with `if: always()`, so it passes only when they all did. | +| `label` | `automerge` | Label that opts a pull request in. | +| `merge-token` | | An API token (a personal access token or an app token) that marks the pull request ready. It needs write on pull requests. | +| `app-id`, `app-private-key` | | A GitHub App to mint an installation token from, instead of `merge-token`. The app needs write on pull requests. | +| `read-token` | `github.token` | Token used to read pull requests and checks. | +| `head-sha` | event's head commit | Commit to act on. | + +## Development + +This repository readies its own labelled drafts with the action (`.github/workflows/ready-when-green.yml`) and merges them with `merge-when-green` beside it (`.github/workflows/merge-when-green.yml`), both with the organisation's GitHub App. The work itself is done by `merge-when`, which holds the script and its tests; `npm test` here runs the unit tests for the release plugins. CI also runs the composite action from the checkout, and commitlint, actionlint, typecheck, lint and format checks, aggregated into one `Required Checks` job. + +Releases are made by semantic-release from conventional commits on `main`: it tags the version, publishes the GitHub Release, updates `CHANGELOG.md` and moves the major tag (`v1`) that consumers pin to. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..9cb8e83 --- /dev/null +++ b/action.yml @@ -0,0 +1,41 @@ +name: Ready when green +description: Mark labelled draft pull requests ready for review once a required check passes, so a merge workflow can take them on. +author: ExaDev +branding: + icon: eye + color: green + +inputs: + required-check: + description: Name of the check run that must have succeeded on the pull request's head commit. Point it at one aggregate job that needs every other job. + required: true + label: + description: Label that opts a pull request in. + default: automerge + merge-token: + description: An API token that marks the pull request ready, a personal access token or a GitHub App installation token. Not GITHUB_TOKEN, which starts no ready_for_review workflows. + app-id: + description: A GitHub App's id, as an alternative to merge-token; an installation token is minted for this repository. + app-private-key: + description: The private key of the GitHub App named by app-id. + read-token: + description: Token used to read pull requests and checks. + default: ${{ github.token }} + head-sha: + description: Commit to act on. Defaults to the head of the workflow_run or pull_request_target event. + default: ${{ github.event.workflow_run.head_sha || github.event.pull_request.head.sha }} + +runs: + using: composite + steps: + - uses: ExaDev/merge-when@v1 + with: + then: ready + when: | + label: ${{ inputs.label }} + check: ${{ inputs.required-check }} + merge-token: ${{ inputs.merge-token }} + app-id: ${{ inputs.app-id }} + app-private-key: ${{ inputs.app-private-key }} + read-token: ${{ inputs.read-token }} + head-sha: ${{ inputs.head-sha }}