diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3f2ddf..dbf46cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,29 +23,94 @@ jobs: # release path. The author and head repo clauses are what make it unforgeable; the branch # name on its own would let any PR, a fork's included, call its branch release-please--x. # Label a Release PR `run-tests` to force the lane anyway. - # - # Keep this job id: it prefixes every check name the reusable workflow produces. - ci: + # The skip also requires the diff to be only what release-please writes: the changelog, the manifest, and in each version file nothing but the version line. A hand-pushed code or dependency change, an unexpected file or a failed lookup runs the unit lane. + release-only: if: >- - ${{ contains(github.event.pull_request.labels.*.name, 'run-tests') - || !(github.event.pull_request.user.login == 'github-actions[bot]' + ${{ github.event.pull_request.user.login == 'github-actions[bot]' && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release-please--')) }} + && startsWith(github.head_ref, 'release-please--') }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + skip: ${{ steps.files.outputs.skip }} + steps: + - id: files + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + VERSION_FILES: version.go + run: | + export FILES="$RUNNER_TEMP/pr-files.jsonl" + if ! gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR}/files" --paginate --jq '.[] | @json' > "$FILES"; then + echo "::warning::Could not list this PR's files; running the unit lane." + echo "skip=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + python3 - <<'PY' + import json, os, re + + files = [json.loads(line) for line in open(os.environ["FILES"]) if line.strip()] + version_files = set(os.environ["VERSION_FILES"].split()) + free = {"CHANGELOG.md", ".release-please-manifest.json"} + version_token = re.compile(r"v?\d+(?:\.\d+)+(?:-[0-9A-Za-z.]+)?") + + + def lines(f, sign): + return [l[1:] for l in (f.get("patch") or "").split("\n") if l.startswith(sign)] + + + def decide(): + manifest = next((f for f in files if f["filename"] == ".release-please-manifest.json"), None) + new = re.findall(r'"\.":\s*"([^"]+)"', "\n".join(lines(manifest, "+"))) if manifest else [] + if len(new) != 1: + return "the manifest diff is not a single version bump" + has_new = re.compile(r"(?- - ${{ github.event.pull_request.user.login == 'github-actions[bot]' - && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release-please--') }} + SKIP: ${{ needs.release-only.outputs.skip }} run: | case "$RESULT" in success) echo "unit lane passed" ;; skipped) - if [ "$RELEASE_PR" = "true" ]; then + if [ "$SKIP" = "true" ]; then echo "release pr: unit lane skipped by design" else - echo "::error::the unit lane was skipped on a PR that is not a Release PR" + echo "::error::the unit lane was skipped without release-only confirming a release-please-only diff" exit 1 fi ;; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e50fab5..4dd4e47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,11 +39,7 @@ jobs: target-branch: ${{ github.ref_name }} skip-github-release: true - # The tag is irreversible and, for Go, permanent: proxy.golang.org caches it forever. - # So the suite has to run before it, which means knowing a release is pending before - # the suite starts. The tag lands on the merged Release PR's merge commit while the - # suite runs on this workflow's own commit, so `ready` also requires those to be the - # same commit. They are, on the path that matters: the push of that merge. + # The tag is irreversible and, for Go, permanent: proxy.golang.org caches it forever, so they run only on the push that merged the Release PR: `ready` requires the pending release's merge commit to be this run's commit. A release from the default branch has no test run, because the Release PR adds only the version bump and changelog to already-tested code; a hotfix release from `N.x` runs the unit lane first, because hotfix commits are pushed without a PR. detect: name: Detect pending release if: github.ref_name == 'main' || endsWith(github.ref_name, '.x') @@ -117,8 +113,7 @@ jobs: echo "No pending release on ${BASE}." elif [ "$sha" != "$HEAD_SHA" ]; then # Reached when an earlier release run failed after the Release PR merged. - # Tagging $sha here would tag a tree this run never tested, and failing - # would redden every later push, so stand down and say why. + # Finishing it from a later push would retry a deterministic failure (a refused major tag, a broken build) on every push, so stand down and say why. pending=true echo "::warning::Release PR #${num} is still pending at ${sha}, which is not this run's commit ${HEAD_SHA}. Re-run the workflow run for ${sha} to finish that release." { @@ -179,9 +174,9 @@ jobs: echo "Release $version will be tagged at $SHA, where go.mod is $module_path." tests: - name: Tests + name: Tests (hotfix only) needs: detect - if: needs.detect.outputs.ready == 'true' + if: needs.detect.outputs.ready == 'true' && github.ref_name != github.event.repository.default_branch uses: ./.github/workflows/run_tests.yml secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -190,7 +185,9 @@ jobs: release: name: 🚀 Tag and release needs: [detect, tests] - if: needs.detect.outputs.ready == 'true' + if: >- + ${{ !cancelled() && needs.detect.result == 'success' && needs.detect.outputs.ready == 'true' + && (needs.tests.result == 'success' || needs.tests.result == 'skipped') }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d2f062..2f1e51f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ CI follows the same split: | --- | --- | --- | | Pull request | `go test -short` on Go 1.19 to 1.24 | yes, `🧪 Tests` | | Daily at 14:00 UTC | the full suite on Go 1.24 | no, a red run opens an issue | -| Push to `main` with a release pending | the unit lane | yes, it gates the tag | +| Release PR merged | nothing on the default branch, the unit lane on `N.x` | `N.x` only | The full suite requires at least two environment variables: `STREAM_API_KEY` and `STREAM_API_SECRET`. There are multiple ways to provide that: - simply set it in your current shell (`export STREAM_API_KEY=xyz`) @@ -62,8 +62,8 @@ Releases are driven by [release-please](https://github.com/googleapis/release-pl - Merge PRs to `main` with conventional-commit titles. The PR title becomes the commit subject and is what determines the next version, so a non-conventional title ships nothing. - release-please keeps a Release PR open with the version bump and the generated changelog. Review it. - The Release PR is opened by `github-actions[bot]`, so its CI runs are held at "action required". If the PR is behind `main`, clicking **Update branch** also releases them, because that commit is attributed to you; otherwise click **Approve and run**. It needs a code-owner approval like any other PR. -- The unit lane does not run on a Release PR, whichever of those two paths you take, and `🧪 Tests` still reports satisfied. A Release PR only bumps the version and rewrites the changelog. `Lint`, `reviewdog`, CodeQL and the PR-title check still run. Label the PR `run-tests` if you want the unit lane anyway. -- Merge the Release PR. That creates the tag and the GitHub Release, and `proxy.golang.org` picks the tag up. There is no separate publish step. The unit lane does run before the tag: `release.yml` calls it on the merge commit, and a failure there leaves `autorelease: pending` set and needs the manual recovery below. Integration tests are advisory and gate none of it. +- The unit lane does not run on a Release PR, whichever of those two paths you take, and `🧪 Tests` still reports satisfied. A Release PR only bumps the version and rewrites the changelog. The skip only applies while the diff is nothing but what release-please writes, down to the version line in each version file, so a code or dependency change pushed onto a Release PR by hand runs the unit lane like any other PR. `Lint`, `reviewdog`, CodeQL and the PR-title check still run. Label the PR `run-tests` if you want the unit lane anyway. +- Merge the Release PR. That creates the tag and the GitHub Release, and `proxy.golang.org` picks the tag up. There is no separate publish step and no further test run: the Release PR adds only the version bump and changelog to an already-tested `main`. A hotfix release from `N.x` runs the unit lane first, since its commits were pushed without a PR. Integration tests are advisory and gate none of it. Only `feat`, `fix`, `perf` and breaking changes produce a release (`revert` may also). A window of only `chore`, `ci`, `docs`, `test`, `refactor`, `style` or `build` commits produces no Release PR, which is intended.