diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74b3259..268c172 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,9 @@ name: ci on: pull_request: push: - branches: [main] + branches: + - main + workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 3d4360a..5954544 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -19,6 +19,9 @@ on: major: description: Major version of the release value: ${{ jobs.release-please.outputs.major }} + pr_branch: + description: Branch of the release pull request this run opened or updated with GITHUB_TOKEN, which starts no workflows; empty otherwise + value: ${{ jobs.release-please.outputs.pr_branch }} permissions: contents: read @@ -35,6 +38,7 @@ jobs: tag_name: ${{ steps.release.outputs.tag_name }} version: ${{ steps.release.outputs.version }} major: ${{ steps.release.outputs.major }} + pr_branch: ${{ steps.pr.outputs.branch }} steps: - id: release uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 @@ -42,3 +46,14 @@ jobs: token: ${{ secrets.token || github.token }} config-file: release-please-config.json manifest-file: .release-please-manifest.json + - name: Release pull request branch + id: pr + if: steps.release.outputs.prs_created == 'true' + env: + PR: ${{ steps.release.outputs.pr }} + OWN_TOKEN: ${{ secrets.token != '' }} + run: | + # a pull request opened with the caller's own token starts CI by itself + if [ "$OWN_TOKEN" = "false" ]; then + echo "branch=$(jq -r .headBranchName <<<"$PR")" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c37cbd1..cdaadd8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,3 +41,14 @@ jobs: git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG" git tag -f "v$MAJOR" "$TAG" git push -f origin "refs/tags/v$MAJOR" + release-pr-ci: + needs: release + if: ${{ needs.release.outputs.pr_branch != '' }} + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ needs.release.outputs.pr_branch }} + run: gh workflow run ci.yml --repo "$GITHUB_REPOSITORY" --ref "$BRANCH" diff --git a/.repokeeper.yml b/.repokeeper.yml index 971f9c1..4ab3a7d 100644 --- a/.repokeeper.yml +++ b/.repokeeper.yml @@ -1,6 +1,6 @@ # repokeeper configuration: https://github.com/vannt-dev/repokeeper schema: 1 -standard: 1.3.2 +standard: 1.4.0 platform: github stacks: - node diff --git a/.repokeeper/lock.json b/.repokeeper/lock.json index 5fe8328..78e4b42 100644 --- a/.repokeeper/lock.json +++ b/.repokeeper/lock.json @@ -1,6 +1,6 @@ { "lockVersion": 1, - "standard": "1.3.2", + "standard": "1.4.0", "entries": [ { "id": "block:.gitattributes#editorconfig", @@ -251,7 +251,7 @@ { "id": "yaml:.github/workflows/ci.yml#[\"on\"]", "module": "ci", - "hash": "8a324c0626f6d862860cca96e075031faea73ddbc51d230f2c6ea2d54088c164", + "hash": "6ba4774ab2129a605bb160c5ee6f48ebc51544881592ad60ad5268b910fcfbde", "target": { "kind": "yaml", "path": ".github/workflows/ci.yml", @@ -272,6 +272,19 @@ ] } }, + { + "id": "yaml:.github/workflows/release.yml#[\"jobs\",\"release-pr-ci\"]", + "module": "release", + "hash": "5fe7f3717447417a686ad02836df7bf9bb3225fb37358364fd13a11251f997c2", + "target": { + "kind": "yaml", + "path": ".github/workflows/release.yml", + "keyPath": [ + "jobs", + "release-pr-ci" + ] + } + }, { "id": "yaml:.github/workflows/release.yml#[\"jobs\",\"release\"]", "module": "release", diff --git a/README.md b/README.md index 9e4d603..6c88dcf 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,12 @@ work: - In the repository settings, under Actions → General, allow GitHub Actions to create and approve pull requests. - Optionally add a `RELEASE_PLEASE_TOKEN` secret (a fine-grained token with contents, pull requests - and issues write access). Without it the release pull request is opened with `GITHUB_TOKEN`, and - GitHub does not run CI on pull requests opened that way. + and issues write access). Without it the release pull request is opened with `GITHUB_TOKEN`, which + starts no workflows, so the `release-pr-ci` job runs `ci.yml` on the release branch itself; its + checks then satisfy required checks in a ruleset. + +A repository with no release yet (manifest at `0.0.0`) gets `initial-version: 0.1.0`, so its first +release is 0.1.0 rather than release-please's default 1.0.0. Set `modules.drift: true` to add a `repokeeper` job to `ci.yml` that runs `repokeeper check` with the version that wrote the standard, so a pull request that edits a managed file fails until the edit diff --git a/src/platforms/github.ts b/src/platforms/github.ts index 6f3653b..6f4138a 100644 --- a/src/platforms/github.ts +++ b/src/platforms/github.ts @@ -144,7 +144,12 @@ export const githubPlatform: PlatformAdapter = { if (jobs.length === 0) return []; return [ workflowKey("ci", path, ["name"], "ci"), - workflowKey("ci", path, ["on"], { pull_request: null, push: { branches: [defaultBranch(ctx.config)] } }), + // workflow_dispatch lets the release workflow run CI on release pull requests (see releaseAutomation) + workflowKey("ci", path, ["on"], { + pull_request: null, + push: { branches: [defaultBranch(ctx.config)] }, + workflow_dispatch: null, + }), workflowKey("ci", path, ["permissions"], { contents: "read" }), // a new push to a pull request makes its earlier run pointless; runs on the default branch always finish workflowKey("ci", path, ["concurrency"], { @@ -159,6 +164,8 @@ export const githubPlatform: PlatformAdapter = { releaseAutomation(ctx: ModuleContext, release: ReleaseInfo): Output[] { const path = ".github/workflows/release.yml"; + const seed = release.version ?? ctx.repo.releasedVersion ?? "0.0.0"; + const hasCi = ctx.config.modules.ci && githubPlatform.ciWorkflow(ctx).length > 0; return [ { kind: "file", @@ -176,6 +183,8 @@ export const githubPlatform: PlatformAdapter = { ...(release.versionFile ? { "version-file": release.versionFile } : {}), // Without it a pom at a release version first gets a pull request that only bumps to -SNAPSHOT ...(release.type === "maven" ? { "skip-snapshot": true } : {}), + // release-please makes a repository's first release 1.0.0 unless told otherwise + ...(seed === "0.0.0" ? { "initial-version": "0.1.0" } : {}), }, }, }), @@ -184,7 +193,7 @@ export const githubPlatform: PlatformAdapter = { kind: "seed", module: "release", path: ".release-please-manifest.json", - content: json({ ".": release.version ?? ctx.repo.releasedVersion ?? "0.0.0" }), + content: json({ ".": seed }), }, workflowKey("release", path, ["name"], "release"), workflowKey("release", path, ["on"], { push: { branches: [defaultBranch(ctx.config)] } }), @@ -195,6 +204,29 @@ export const githubPlatform: PlatformAdapter = { // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template secrets: { token: "${{ secrets.RELEASE_PLEASE_TOKEN }}" }, }), + // Pull requests opened with GITHUB_TOKEN start no workflows, so required checks would never report + ...(hasCi + ? [ + workflowKey("release", path, ["jobs", "release-pr-ci"], { + needs: "release", + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + if: "${{ needs.release.outputs.pr_branch != '' }}", + "runs-on": "ubuntu-latest", + permissions: { actions: "write" }, + steps: [ + { + env: { + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + GH_TOKEN: "${{ github.token }}", + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + BRANCH: "${{ needs.release.outputs.pr_branch }}", + }, + run: 'gh workflow run ci.yml --repo "$GITHUB_REPOSITORY" --ref "$BRANCH"', + }, + ], + }), + ] + : []), ]; }, }; diff --git a/src/version.ts b/src/version.ts index ecbebfa..b6a7392 100644 --- a/src/version.ts +++ b/src/version.ts @@ -5,7 +5,7 @@ export const PACKAGE_VERSION: string = ( ).version; /** The standard this build of repokeeper applies. Bump it whenever generated output changes. */ -export const STANDARD_VERSION = "1.3.2"; +export const STANDARD_VERSION = "1.4.0"; export const TOOL_VERSIONS = { lefthook: "2.1.14", diff --git a/test/modules-ci-release.test.ts b/test/modules-ci-release.test.ts index e3f9e86..8237cf9 100644 --- a/test/modules-ci-release.test.ts +++ b/test/modules-ci-release.test.ts @@ -15,7 +15,7 @@ describe("ci module", () => { it("calls the reusable workflows at the moving major tag", () => { const out = keys(ciModule.outputs(makeContext())); expect(out.name).toBe("ci"); - expect(out.on).toEqual({ pull_request: null, push: { branches: ["main"] } }); + expect(out.on).toEqual({ pull_request: null, push: { branches: ["main"] }, workflow_dispatch: null }); expect(out.permissions).toEqual({ contents: "read" }); expect(out["jobs.node"]).toEqual({ uses: `vannt-dev/repokeeper/.github/workflows/stack-node.yml@${WORKFLOW_REF}`, @@ -35,7 +35,7 @@ describe("ci module", () => { it("follows the configured default branch and drops the commits job with the commits module", () => { const ctx = makeContext({ config: { github: { default_branch: "trunk" } }, modules: { commits: false } }); const out = keys(ciModule.outputs(ctx)); - expect(out.on).toEqual({ pull_request: null, push: { branches: ["trunk"] } }); + expect(out.on).toEqual({ pull_request: null, push: { branches: ["trunk"] }, workflow_dispatch: null }); expect(out["jobs.commits"]).toBeUndefined(); }); @@ -105,6 +105,46 @@ describe("release module", () => { }); }); + it("starts a repository with no release yet at 0.1.0 instead of release-please's 1.0.0", () => { + const pkg = (version: string | null) => { + const outputs = releaseModule.outputs( + makeContext({ stacks: [nodeResolved({ release: { type: "simple", version } })] }), + ); + const config = outputs.find((o) => o.path === "release-please-config.json"); + return JSON.parse(config?.kind === "file" ? config.content : "").packages["."]; + }; + expect(pkg(null)["initial-version"]).toBe("0.1.0"); + expect(pkg("0.3.0")).not.toHaveProperty("initial-version"); + }); + + it("runs CI on release pull requests opened with GITHUB_TOKEN, since those start no workflows", () => { + const out = keys(releaseModule.outputs(makeContext())); + expect(out["jobs.release-pr-ci"]).toEqual({ + needs: "release", + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + if: "${{ needs.release.outputs.pr_branch != '' }}", + "runs-on": "ubuntu-latest", + permissions: { actions: "write" }, + steps: [ + { + env: { + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + GH_TOKEN: "${{ github.token }}", + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + BRANCH: "${{ needs.release.outputs.pr_branch }}", + }, + run: 'gh workflow run ci.yml --repo "$GITHUB_REPOSITORY" --ref "$BRANCH"', + }, + ], + }); + }); + + it("leaves that job out when the repository has no ci workflow", () => { + expect(keys(releaseModule.outputs(makeContext({ modules: { ci: false } })))).not.toHaveProperty( + "jobs.release-pr-ci", + ); + }); + it("prefers a language release type and falls back to simple at 0.0.0", () => { const simple = nodeResolved({ id: "node", release: { type: "simple", version: null } }); expect(pickRelease([simple, nodeResolved({ release: { type: "node", version: "2.0.0" } })])).toEqual({ diff --git a/test/workflows.test.ts b/test/workflows.test.ts index 44f4675..d7c61ac 100644 --- a/test/workflows.test.ts +++ b/test/workflows.test.ts @@ -19,6 +19,8 @@ import type { StackPack } from "../src/stacks/types.js"; import { TOOL_VERSIONS } from "../src/version.js"; interface Step { + if?: string; + env?: Record; name?: string; uses?: string; run?: string; @@ -182,6 +184,16 @@ describe("commitlint config in the reusable workflow", () => { it("release-please exposes the outputs callers use", () => { expect(Object.keys(workflow("release-please.yml").on.workflow_call?.outputs ?? {})).toEqual( - expect.arrayContaining(["release_created", "tag_name", "version", "major"]), + expect.arrayContaining(["release_created", "tag_name", "version", "major", "pr_branch"]), ); }); + +it("release-please names the release branch only when GITHUB_TOKEN opened the pull request", () => { + const steps = workflow("release-please.yml").jobs["release-please"]?.steps ?? []; + const pr = steps.find((s) => s.name === "Release pull request branch"); + expect(pr?.if).toBe("steps.release.outputs.prs_created == 'true'"); + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + expect(pr?.env).toMatchObject({ OWN_TOKEN: "${{ secrets.token != '' }}" }); + expect(pr?.run).toContain(`if [ "$OWN_TOKEN" = "false" ]`); + expect(pr?.run).toContain("jq -r .headBranchName"); +});