diff --git a/.github/workflows/Self-Release.yml b/.github/workflows/Self-Release.yml new file mode 100644 index 0000000..f6fe089 --- /dev/null +++ b/.github/workflows/Self-Release.yml @@ -0,0 +1,36 @@ +# This repository's own caller of the reusable release.yml, which other repositories call as BioJulia/Workflows/.github/workflows/release.yml@v1; the Self- files apply this repository's workflows to itself. +# Started by hand, from the Actions tab: it prepares release/vX.Y.Z from develop, or hotfix/vX.Y.Z from master, with ReleaseFlow's version, the changelog's section and the pins of BioJulia/Workflows set, and opens its pull request into master. +name: "Self: Release" + +on: + workflow_dispatch: + inputs: + kind: + description: A release, from develop, or a hotfix, from master. + type: choice + options: [release, hotfix] + default: release + step: + description: The step of the version; `auto` reads the changelog's unreleased section. + type: choice + options: [auto, patch, minor, major] + default: auto + +permissions: {} + +jobs: + release: + uses: ./.github/workflows/release.yml + permissions: + contents: read + with: + model: gitflow + kind: ${{ inputs.kind }} + step: ${{ inputs.step }} + main-branch: master + project: ReleaseFlow + pin-action: BioJulia/Workflows + secrets: + RELEASEFLOW_APP_CLIENT_ID: ${{ secrets.RELEASEFLOW_APP_CLIENT_ID }} + RELEASEFLOW_APP_PRIVATE_KEY: ${{ secrets.RELEASEFLOW_APP_PRIVATE_KEY }} + RELEASEFLOW_TOKEN: ${{ secrets.RELEASEFLOW_TOKEN }} diff --git a/.github/workflows/back-merge.yml b/.github/workflows/back-merge.yml index c7a4fa4..af51a87 100644 --- a/.github/workflows/back-merge.yml +++ b/.github/workflows/back-merge.yml @@ -30,7 +30,7 @@ # The pull request comes from a branch at the commit that it brings: back-merge/vX.Y.Z from the main branch, named after its release, and back-merge/- from any other, so that a conflict is resolved on that branch, by merging the branch that it goes into into it, without pushing to either. # Pull requests opened with the default GITHUB_TOKEN don't start workflows, so their required checks would never report. # Another token avoids that, with read and write access to pull requests, while the job's own token creates the branch; where GitHub refuses the job's token that branch, because its workflow files differ, the other token creates it, and then needs read and write access to contents and workflows too: -# - a GitHub App's, created for each run from the secrets RELEASEFLOW_APP_CLIENT_ID and RELEASEFLOW_APP_PRIVATE_KEY, which opens the pull request as the app's bot; +# - the ReleaseFlow app's, created for each run from the secrets RELEASEFLOW_APP_CLIENT_ID and RELEASEFLOW_APP_PRIVATE_KEY, which opens the pull request as the app's bot; # - or the secret RELEASEFLOW_TOKEN, a fine-grained personal access token, which opens it as the token's owner. # Without either, the default token opens the pull request, which says to close and reopen it, so that the checks run. # Either way, the repository must allow GitHub Actions to create pull requests, in Settings → Actions → General. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c3dcf6a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,199 @@ +# Release: prepare a release or hotfix, its branch with the version, the changelog's section and the pins done, and its pull request, which the checks then pass. +# Called from the repository's own workflow, started by hand from the Actions tab: +# +# on: +# workflow_dispatch: +# inputs: +# kind: +# type: choice +# options: [release, hotfix] +# step: +# type: choice +# options: [auto, patch, minor, major] +# jobs: +# release: +# uses: BioJulia/Workflows/.github/workflows/release.yml@v1 +# permissions: +# contents: read +# with: +# kind: ${{ inputs.kind }} +# step: ${{ inputs.step }} +# secrets: +# RELEASEFLOW_APP_CLIENT_ID: ${{ secrets.RELEASEFLOW_APP_CLIENT_ID }} +# RELEASEFLOW_APP_PRIVATE_KEY: ${{ secrets.RELEASEFLOW_APP_PRIVATE_KEY }} +# RELEASEFLOW_TOKEN: ${{ secrets.RELEASEFLOW_TOKEN }} +# +# A release starts from develop under gitflow, and from the main branch otherwise; a hotfix starts from the main branch, or under OneFlow from the last release's tag. +# The step `auto` reads the changelog's unreleased section: `### Breaking` takes a breaking version, `### Added` the next minor version, and anything else the next patch; a hotfix is always the next patch, and its section waits for the fix's entry, so its pull request is a draft. +# The branch and pull request need a token other than GITHUB_TOKEN, whose pull requests start no checks, and which can't push workflow files: the ReleaseFlow app's, from RELEASEFLOW_APP_CLIENT_ID and RELEASEFLOW_APP_PRIVATE_KEY, or the personal access token RELEASEFLOW_TOKEN. +# The job runs in the deployment environment `environment`, `releaseflow` unless given, which keeps those secrets and allows the main and develop branches. +name: "Reusable: Release" + +on: + workflow_call: + inputs: + model: + description: The branching model, `gitflow`, `oneflow` or `trunk`. + type: string + default: gitflow + kind: + description: The kind of release, `release` or `hotfix`. + type: string + default: release + step: + description: The step of the version, `auto`, `patch`, `minor` or `major`. + type: string + default: auto + main-branch: + description: The branch of releases. + type: string + default: main + develop-branch: + description: The branch of unreleased work, for gitflow. + type: string + default: develop + project: + description: The package's directory, relative to the repository's root, for a package in a subdirectory. + type: string + default: '' + pin-action: + description: 'An action, such as `BioJulia/Workflows`, whose `uses: …@vX.Y.Z` pins in the workflows take the new version.' + type: string + default: '' + check-links: + description: Whether to set the changelog's comparison links. + type: boolean + default: true + registry: + description: The registry of the package's releases. + type: string + default: General + environment: + description: The deployment environment that holds the secrets, limited to the main and develop branches. + type: string + default: releaseflow + secrets: + RELEASEFLOW_APP_CLIENT_ID: + description: The client ID of the ReleaseFlow app, a GitHub App installed on the repository. + required: false + RELEASEFLOW_APP_PRIVATE_KEY: + description: A private key of the ReleaseFlow app. + required: false + RELEASEFLOW_TOKEN: + description: A personal access token, where there's no ReleaseFlow app. + required: false + +permissions: {} + +jobs: + prepare: + name: Prepare + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + # The branch and pull request take the ReleaseFlow app's token, or the personal one; the job's own only reads. + permissions: + contents: read + steps: + # Secrets can't be tested in a step's condition, so this step tells whether a GitHub App is given. + - id: app-given + env: + CLIENT_ID: ${{ secrets.RELEASEFLOW_APP_CLIENT_ID }} + run: echo "given=$([ -n "$CLIENT_ID" ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + - id: app + if: steps.app-given.outputs.given == 'true' + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ secrets.RELEASEFLOW_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASEFLOW_APP_PRIVATE_KEY }} + - name: Check for a token + env: + HAS_TOKEN: ${{ steps.app.outputs.token != '' || secrets.RELEASEFLOW_TOKEN != '' }} + run: | + if [ "$HAS_TOKEN" != "true" ]; then + echo "::error title=Release::Preparing a release needs the ReleaseFlow app's secrets, RELEASEFLOW_APP_CLIENT_ID and RELEASEFLOW_APP_PRIVATE_KEY, or RELEASEFLOW_TOKEN, since the default token's pull requests start no checks." + exit 1 + fi + # A release starts from develop under gitflow, and from the main branch otherwise, as does a hotfix. + - id: start + env: + RELEASEFLOW_MODEL: ${{ inputs.model }} + RELEASEFLOW_KIND: ${{ inputs.kind }} + RELEASEFLOW_MAIN_BRANCH: ${{ inputs.main-branch }} + RELEASEFLOW_DEVELOP_BRANCH: ${{ inputs.develop-branch }} + run: | + if [ "$RELEASEFLOW_MODEL" = gitflow ] && [ "$RELEASEFLOW_KIND" = release ]; then + echo "ref=$RELEASEFLOW_DEVELOP_BRANCH" >> "$GITHUB_OUTPUT" + else + echo "ref=$RELEASEFLOW_MAIN_BRANCH" >> "$GITHUB_OUTPUT" + fi + # With the token that pushes the branch, which may carry workflow files. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.start.outputs.ref }} + fetch-depth: 0 + token: ${{ steps.app.outputs.token || secrets.RELEASEFLOW_TOKEN }} + - name: Start a OneFlow hotfix from the last release's tag + if: inputs.model == 'oneflow' && inputs.kind == 'hotfix' + env: + RELEASEFLOW_PROJECT: ${{ inputs.project }} + run: | + set -euo pipefail + version=$(sed -n 's/^version = "\(.*\)"$/\1/p' "${RELEASEFLOW_PROJECT:+${RELEASEFLOW_PROJECT%/}/}Project.toml") + git switch --detach "v$version" + - id: prepare + uses: BioJulia/Workflows@v1.0.1 + with: + check: prepare + model: ${{ inputs.model }} + project: ${{ inputs.project }} + kind: ${{ inputs.kind }} + step: ${{ inputs.step }} + main-branch: ${{ inputs.main-branch }} + develop-branch: ${{ inputs.develop-branch }} + base-ref: origin/${{ inputs.main-branch }} + pin-action: ${{ inputs.pin-action }} + check-links: ${{ inputs.check-links }} + registry: ${{ inputs.registry }} + - name: Push the branch and open the pull request + env: + GH_TOKEN: ${{ steps.app.outputs.token || secrets.RELEASEFLOW_TOKEN }} + APP_SLUG: ${{ steps.app.outputs.app-slug }} + REPOSITORY: ${{ github.repository }} + RELEASEFLOW_KIND: ${{ inputs.kind }} + VERSION: ${{ steps.prepare.outputs.version }} + BRANCH: ${{ steps.prepare.outputs.branch }} + BASE: ${{ steps.prepare.outputs.base }} + NOTES: ${{ steps.prepare.outputs.notes }} + run: | + set -euo pipefail + # A release's branch is new; one that exists is an earlier preparation, which a maintainer finishes or deletes. + if git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null; then + echo "::error title=Release::The branch $BRANCH exists already; finish its release, or delete the branch to prepare it again." + exit 1 + fi + + # The commit is the app's bot's, or, with a personal token, GitHub Actions'. + if [ -n "$APP_SLUG" ]; then + name="${APP_SLUG}[bot]" + email="$(gh api "users/$name" --jq .id)+$name@users.noreply.github.com" + else + name="github-actions[bot]" + email="41898282+github-actions[bot]@users.noreply.github.com" + fi + if [ "$RELEASEFLOW_KIND" = hotfix ]; then title="Hotfix $VERSION"; else title="Release $VERSION"; fi + git switch -q -c "$BRANCH" + git -c user.name="$name" -c user.email="$email" commit -q -a -m "$title" + git push -q origin "$BRANCH" + echo "Pushed the branch $BRANCH." + + body="This prepares the $RELEASEFLOW_KIND $VERSION: its version in Project.toml, its section of the changelog, with its links, and its pins. + + $NOTES" + draft=false + if [ "$RELEASEFLOW_KIND" = hotfix ]; then + body="$body + + Push the fix, with its entry in the section of $VERSION, and mark the pull request ready for review." + draft=true + fi + gh pr create --repo "$REPOSITORY" --base "$BASE" --head "$BRANCH" --title "$title" --body "$body" --draft="$draft" diff --git a/README.md b/README.md index e0a7f27..2a87f79 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ jobs: permissions: contents: write pull-requests: write - # A GitHub App's ID and private key, or a personal access token, so that the pull request's checks run; see below. + # The ReleaseFlow app's client ID and private key, or a personal access token, so that the pull request's checks run; see below. secrets: RELEASEFLOW_APP_CLIENT_ID: ${{ secrets.RELEASEFLOW_APP_CLIENT_ID }} RELEASEFLOW_APP_PRIVATE_KEY: ${{ secrets.RELEASEFLOW_APP_PRIVATE_KEY }} @@ -106,6 +106,40 @@ Each dependent is checked out at its default branch, and its tests run as the ch A breaking release is expected to break its dependents until they adapt, so whether a ruleset requires these checks is the maintainers' choice; without that, they report what a release would break before it's registered. A dependent whose project takes the package from a local path, through `[sources]`, tests against that path instead. +And the release, in `.github/workflows/Release.yml`, started by hand from the Actions tab, which prepares a release or a hotfix and opens its pull request: + +```yaml +name: Release +on: + workflow_dispatch: + inputs: + kind: + type: choice + options: [release, hotfix] + step: + type: choice + options: [auto, patch, minor, major] +permissions: {} +jobs: + release: + uses: BioJulia/Workflows/.github/workflows/release.yml@v1 + permissions: + contents: read + with: + model: gitflow # or oneflow, or trunk + kind: ${{ inputs.kind }} + step: ${{ inputs.step }} + secrets: + RELEASEFLOW_APP_CLIENT_ID: ${{ secrets.RELEASEFLOW_APP_CLIENT_ID }} + RELEASEFLOW_APP_PRIVATE_KEY: ${{ secrets.RELEASEFLOW_APP_PRIVATE_KEY }} + RELEASEFLOW_TOKEN: ${{ secrets.RELEASEFLOW_TOKEN }} +``` + +It starts a release from `develop` under gitflow, and from the main branch otherwise, and a hotfix from the main branch, or under OneFlow from the last release's tag. +On a new branch, `release/vX.Y.Z` or `hotfix/vX.Y.Z`, it sets the version in `Project.toml`, moves the changelog's unreleased entries into a dated `## [X.Y.Z]` section with its comparison links, and, with `pin-action`, sets that action's `uses: …@vX.Y.Z` pins in the workflows; then it opens the pull request into the main branch, which the checks pass. +The step `auto` reads the unreleased section: a `### Breaking` subsection takes a breaking version, the next major version, or below 1.0 the next minor; `### Added` takes the next minor version; anything else, the next patch. +A hotfix is always the next patch, and its section waits for the fix's entry, so its pull request is a draft until the fix is pushed. + OneFlow and trunk, in `.github/workflows/OneFlow.yml` or `.github/workflows/Trunk.yml`: ```yaml @@ -162,16 +196,21 @@ A package can follow the major tag, `v1`, or pin a release, such as `v1.0.0`; De | Input | Default | Workflows | Meaning | |:--|:--|:--|:--| | `main-branch` | `main` | all | The branch of releases. | -| `develop-branch` | `develop` | gitflow, back-merge | The branch of unreleased work. | +| `develop-branch` | `develop` | gitflow, back-merge, release | The branch of unreleased work. | | `from` | the main branch | back-merge | The branch to bring. | | `into` | the develop branch | back-merge | The branch to bring it into. | | `skip-label` | `skip changelog` | gitflow, oneflow, trunk | The label that exempts a pull request from the changelog entry. | | `check-links` | `true` | gitflow, oneflow, trunk | Whether to check the changelog's comparison links. | | `require-entry` | `true` | trunk | Whether a pull request that isn't a release must add a changelog entry, for a changelog without an unreleased section. | -| `project` | the root | gitflow, back-merge, oneflow, trunk, dependents | The package's directory, for a package in a subdirectory, such as `lib/Toy`. | -| `registry` | `General` | gitflow, oneflow, trunk | The registry of the package's releases. | +| `project` | the root | gitflow, back-merge, oneflow, trunk, dependents, release | The package's directory, for a package in a subdirectory, such as `lib/Toy`. | +| `registry` | `General` | gitflow, oneflow, trunk, release | The registry of the package's releases. | | `dependents` | none | dependents | The repositories of the packages that depend on this one, one per line or separated by commas. | | `julia-version` | `1` | dependents | The version of Julia that the dependents' tests run on. | +| `model` | `gitflow` | release | The branching model, `gitflow`, `oneflow` or `trunk`. | +| `kind` | `release` | release | A release, or a hotfix. | +| `step` | `auto` | release | The step of the version, `auto`, `patch`, `minor` or `major`. | +| `pin-action` | none | release | An action whose pins in the workflows take the new version, such as `BioJulia/Workflows` for this repository. | +| `environment` | `releaseflow` | back-merge, release | The deployment environment that holds the secrets. | For example, `with: { main-branch: master }` for a repository whose main branch is `master`. @@ -185,12 +224,12 @@ For example, `with: { main-branch: master }` for a repository whose main branch The checks that don't apply to a pull request are skipped, and GitHub counts a skipped check as passed, so a ruleset can require all of them. GitHub only offers a check's name once it has run, and only enforces rulesets on a private repository with a paid plan. - **The label.** Create the `skip changelog` label, for changes that users won't notice. -- **The ReleaseFlow credentials.** Pull requests opened with the default `GITHUB_TOKEN` don't start workflows, so their checks would never report. - The back-merge opens them with another token, in order of preference: - - a GitHub App's, which it creates for each run from the secrets `RELEASEFLOW_APP_CLIENT_ID` and `RELEASEFLOW_APP_PRIVATE_KEY`, as described in [Creating the ReleaseFlow app](#creating-the-releaseflow-app); +- **The ReleaseFlow credentials.** Pull requests opened with the default `GITHUB_TOKEN` don't start workflows, so their checks would never report, and it can't push workflow files. + The release and back-merge workflows use another token, in order of preference: + - the ReleaseFlow app's, which they create for each run from the secrets `RELEASEFLOW_APP_CLIENT_ID` and `RELEASEFLOW_APP_PRIVATE_KEY`, as described in [Creating the ReleaseFlow app](#creating-the-releaseflow-app); - a fine-grained personal access token, in the secret `RELEASEFLOW_TOKEN`, as described in [Creating the ReleaseFlow token](#creating-the-releaseflow-token). - Without either, the back-merge pull request says to close and reopen it, which starts the checks. + The release needs one of them; without either, the back-merge pull request says to close and reopen it, which starts the checks. Keep these secrets in the repository's `releaseflow` environment, as described in [The ReleaseFlow environment](#the-releaseflow-environment). Either way, allow GitHub Actions to create pull requests, in the repository's Settings → Actions → General → Workflow permissions → "Allow GitHub Actions to create and approve pull requests". - **Code owners.** A pull request runs its own copy of its workflow files, so a `CODEOWNERS` entry for `/.github/`, such as `/.github/ @/maintainers` in `.github/CODEOWNERS`, with the ruleset requiring a code owner's review, keeps a pull request from changing which checks it runs. @@ -199,39 +238,33 @@ For example, `with: { main-branch: master }` for a repository whose main branch ### The ReleaseFlow environment Any workflow that runs in a repository can read its repository and organization secrets, whatever its branch: anyone who can push a branch, or run a workflow from one, could read the ReleaseFlow credentials with a workflow of their own. -An environment's secrets reach only the jobs on the branches that the environment allows, so the back-merge job runs in the environment `releaseflow`, which its input `environment` names: +An environment's secrets reach only the jobs on the branches that the environment allows, so the release and back-merge jobs run in the environment that their input `environment` names, `releaseflow`: 1. Open the repository's Settings → Environments, and choose "New environment", named `releaseflow`. -2. Under "Deployment branches and tags", choose "Selected branches and tags", and add a rule for the main branch only, such as `master`. - For the tag workflow, add rules for the repository's default branch, such as `develop`, and for tags matching `v*`, whose ruleset limits who can make them. -3. Under "Environment secrets", add the back-merge's secrets: `RELEASEFLOW_APP_CLIENT_ID` and `RELEASEFLOW_APP_PRIVATE_KEY` for an app, or `RELEASEFLOW_TOKEN` for a personal access token. +2. Under "Deployment branches and tags", choose "Selected branches and tags", and add rules for the main branch, such as `master`, where the back-merge runs; the branch that releases are started from in the Actions tab, such as `develop`, the default branch, where the tag workflow runs too; and tags matching `v*`, on which the tag workflow runs, whose ruleset limits who can make them. +3. Under "Environment secrets", add the ReleaseFlow credentials: `RELEASEFLOW_APP_CLIENT_ID` and `RELEASEFLOW_APP_PRIVATE_KEY` for the app, or `RELEASEFLOW_TOKEN` for a personal access token. GitHub uses the environment's secrets in place of secrets of the same name that the caller passes, so the caller needs no change. -Without the environment, GitHub creates it unprotected on the first run, and the back-merge works with repository or organization secrets, without that protection. +Without the environment, GitHub creates it unprotected on the first run, and the workflows work with repository or organization secrets, without that protection. ### Creating the ReleaseFlow app -A GitHub App belongs to the organization rather than to a person, so the pull requests it opens come from its bot, `[bot]`, which any maintainer can approve, and its tokens last an hour, so there's nothing to renew. -One app serves every repository of the organization that uses the back-merge workflow. +A GitHub App belongs to the organization rather than to a person, so the branches and pull requests it opens come from its bot, `[bot]`, which any maintainer can approve, and its tokens last an hour, so there's nothing to renew. +One app serves every repository of the organization that uses the release or back-merge workflow. 1. Open the organization's Settings → Developer settings → GitHub Apps, and choose "New GitHub App". 2. Name it, such as ` ReleaseFlow`, which must be unique on GitHub, and give its homepage URL, such as the organization's page. 3. Under Webhook, clear "Active": the workflow calls the app, rather than the app listening for events. 4. Under repository permissions, set: - - Pull requests: Read and write, to open the pull request. - - The job's own token creates the `back-merge/vX.Y.Z` branch, so nothing more is needed. - GitHub may refuse the job's token that branch, when its workflow files differ from the default branch's, since that token can't have access to workflows; the workflow then logs the refusal and creates the branch with this token instead, which then also needs: - - Contents: Read and write, to create the branch; - - Workflows: Read and write, for the branch's workflow files. - - Add those two only if a back-merge's log shows that refusal. - For the tag workflow, the app needs Contents: Read and write, to move the major tags. + - Contents: Read and write, to push a release's branch, and for the tag workflow, to move the major tags; + - Pull requests: Read and write, to open the pull request; + - Workflows: Read and write, since a release's branch may change workflow files, such as an action's pins. Metadata: Read-only is added by itself; leave every other permission at no access. + A repository that only back-merges can give Pull requests alone, since the back-merge's job creates its branch with its own token, unless GitHub refuses it, which its log then says. 5. Under "Where can this GitHub App be installed?", choose "Only on this account", and choose "Create GitHub App". 6. On the app's page, note its Client ID, and under Private keys choose "Generate a private key", which downloads a `.pem` file. -7. Choose "Install App", install it on the organization, and choose "Only select repositories", with the repositories that use the back-merge workflow. +7. Choose "Install App", install it on the organization, and choose "Only select repositories", with the repositories that use the release or back-merge workflow. 8. In each repository's `releaseflow` environment, add two environment secrets: - `RELEASEFLOW_APP_CLIENT_ID`, the Client ID; - `RELEASEFLOW_APP_PRIVATE_KEY`, the whole contents of the `.pem` file, from `-----BEGIN RSA PRIVATE KEY-----` to `-----END RSA PRIVATE KEY-----`. @@ -239,35 +272,21 @@ One app serves every repository of the organization that uses the back-merge wor Then delete the downloaded file; a new key can be generated if it's lost, and a key can be revoked on the app's page if it leaks. The caller passes both secrets to the reusable workflow, which creates the app's token for its repository with `actions/create-github-app-token`. -To check it, run the caller's Back-merge workflow from the Actions tab, with "Run workflow": it opens the pull request as `[bot]` if `develop` lacks the main branch's commit, and its checks start. +To check it, run the caller's Release workflow from the Actions tab: it pushes `release/vX.Y.Z` and opens its pull request as `[bot]`, and its checks start. ### Creating the ReleaseFlow token Where a GitHub App can't be created, such as for a repository of a personal account, a fine-grained personal access token does the same, in the secret `RELEASEFLOW_TOKEN`. -The back-merge workflow uses it to open its pull request. -GitHub shows the token's owner as the pull request's author, so prefer a maintainers' machine account, such as a bot account of the organization, to a person's: an author can't approve their own pull request, and the token outlives a maintainer's involvement. +GitHub shows the token's owner as the author of the branches and pull requests, so prefer a maintainers' machine account, such as a bot account of the organization, to a person's: an author can't approve their own pull request, and the token outlives a maintainer's involvement. 1. Signed in as the token's owner, open Settings → Developer settings → Personal access tokens → Fine-grained tokens, and choose "Generate new token". 2. Name it, such as `RELEASEFLOW_TOKEN for `, and set an expiration; the organization may cap it, and the token must be regenerated and the secret updated before it expires. 3. Set the resource owner to the organization that owns the repositories, rather than the account; an organization that reviews fine-grained tokens holds it as pending until an owner approves it, in the organization's Settings → Personal access tokens → Pending requests. -4. Under repository access, choose "Only select repositories", and select the repositories that use the back-merge workflow. -5. Under repository permissions, set: - - Pull requests: Read and write, to open the pull request. - - The job's own token creates the `back-merge/vX.Y.Z` branch, so nothing more is needed. - GitHub may refuse the job's token that branch, when its workflow files differ from the default branch's, since that token can't have access to workflows; the workflow then logs the refusal and creates the branch with this token instead, which then also needs: - - Contents: Read and write, to create the branch; - - Workflows: Read and write, for the branch's workflow files. - - Add those two only if a back-merge's log shows that refusal. - - Metadata: Read-only is added by itself; leave every other permission at no access. +4. Under repository access, choose "Only select repositories", and select the repositories that use the release or back-merge workflow. +5. Under repository permissions, set Contents, Pull requests and Workflows to Read and write, as for the app. 6. Choose "Generate token", and copy it; GitHub shows it only once. 7. Store it as an environment secret named `RELEASEFLOW_TOKEN`, in each repository's `releaseflow` environment. -The caller passes the secret to the reusable workflow, as `secrets: RELEASEFLOW_TOKEN: ${{ secrets.RELEASEFLOW_TOKEN }}`; the token never needs to appear anywhere else. -To check it, run the caller's Back-merge workflow from the Actions tab, with "Run workflow": it opens the pull request if `develop` lacks the main branch's commit, and its checks start. - ## The checks - **Source branch.** @@ -314,7 +333,7 @@ This repository follows gitflow, with `master` and `develop`, and checks its own The workflows and ReleaseFlow share one version, in `ReleaseFlow/Project.toml`, and one changelog. The reusable workflows call the action at the release that they belong to, such as `BioJulia/Workflows@v1.0.0`, so that a workflow and its checks come from one release; the CI's Pins check holds them to it. -A release or hotfix branch: +A release or hotfix branch, which `Self-Release.yml` prepares from the Actions tab: 1. sets its version in `ReleaseFlow/Project.toml`; 2. sets every `uses: BioJulia/Workflows@…` in `.github/workflows/` to that version, `vX.Y.Z`; 3. moves the changelog's unreleased entries into a `## [X.Y.Z]` section, with its comparison link. diff --git a/ReleaseFlow/CHANGELOG.md b/ReleaseFlow/CHANGELOG.md index 54d997b..b352ae9 100644 --- a/ReleaseFlow/CHANGELOG.md +++ b/ReleaseFlow/CHANGELOG.md @@ -12,6 +12,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - This repository moves `v1` with it, through `Self-Tag.yml`, once TagBot tags a release `vX.Y.Z`, with `tag_prefix: NO_PREFIX`, in place of `ReleaseFlow-vX.Y.Z`, and with the ReleaseFlow app's token, which the ruleset on `v*` tags lets through; so the workflows' callers get a release once ReleaseFlow's is registered. - The reusable workflow `dependents.yml`, which runs the tests of the packages that depend on this one, each checked out at its default branch, with the pull request's branch in its place, as the checks `dependents / `. - The back-merge's inputs `from` and `into`, which bring any branch into any other, such as a maintenance branch into the main branch, from a branch `back-merge/-`; by default, the main branch into develop, as before. +- `prepare`, which prepares a release in a checkout of the branch it starts from: the next version after the last release, by `RELEASEFLOW_STEP`, where `auto` reads the unreleased section's `### Breaking` and `### Added`, set in `Project.toml`; the unreleased entries moved into its dated section, with the changelog's links; and the pins of `RELEASEFLOW_PIN_ACTION` set to it. +- The reusable workflow `release.yml`, started from the Actions tab, which prepares a release or hotfix with `prepare`, pushes its branch, and opens its pull request, a draft for a hotfix, with the ReleaseFlow app's token; and `Self-Release.yml`, which prepares this repository's releases. ## [1.0.1] - 2026-10-20 diff --git a/ReleaseFlow/Project.toml b/ReleaseFlow/Project.toml index 3c9fdf4..b0a134a 100644 --- a/ReleaseFlow/Project.toml +++ b/ReleaseFlow/Project.toml @@ -4,11 +4,13 @@ version = "1.0.1" authors = ["BioJulia"] [deps] +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" [compat] +Dates = "1" Pkg = "1" TOML = "1" UUIDs = "1" diff --git a/ReleaseFlow/README.md b/ReleaseFlow/README.md index 99bdccb..4c5d5db 100644 --- a/ReleaseFlow/README.md +++ b/ReleaseFlow/README.md @@ -22,7 +22,7 @@ RELEASEFLOW_MODEL=gitflow RELEASEFLOW_BASE_BRANCH=master RELEASEFLOW_HEAD_REF=re From a clone of BioJulia/Workflows, `--project=path/to/Workflows/ReleaseFlow` runs the clone's copy instead. `julia -m` needs Julia 1.12 or later. -The checks are `source`, `version`, `changelog`, `breaking`, `entry` and `is-release`; the exit code is `0` where the check passes, `1` where it fails, with the failures as GitHub Actions annotations, and `2` for a wrong check or model. +The checks are `source`, `version`, `changelog`, `breaking`, `entry` and `is-release`, and `prepare` prepares a release, in a checkout of the branch it starts from: it sets the version in `Project.toml`, moves the changelog's unreleased entries into the new version's section with its links, sets an action's pins, and writes the new branch's name, its base, the version and its notes as outputs; the exit code is `0` where the check passes, `1` where it fails, with the failures as GitHub Actions annotations, and `2` for a wrong check or model. ## The initial release @@ -52,6 +52,10 @@ The inputs are environment variables, so that branch names never reach a shell, | `RELEASEFLOW_SKIP_LABEL` | `skip changelog` | That label, for messages. | | `RELEASEFLOW_CHECK_LINKS` | `true` | Whether to check the changelog's comparison links. | | `RELEASEFLOW_PROJECT` | the root | The package's directory, relative to the repository's root, for a package in a subdirectory, such as `lib/Toy`; its `Project.toml` and `CHANGELOG.md` are read there, and its releases are its directory's trees. | +| `RELEASEFLOW_KIND` | `release` | For `prepare`: a `release` or a `hotfix`. | +| `RELEASEFLOW_STEP` | `auto` | For `prepare`: the step of the version, `auto`, `patch`, `minor` or `major`. | +| `RELEASEFLOW_DATE` | today, in UTC | For `prepare`: the date of the release's section. | +| `RELEASEFLOW_PIN_ACTION` | none | For `prepare`: an action, such as `BioJulia/Workflows`, whose pins in `.github/workflows/` take the new version. | | `RELEASEFLOW_REGISTRY` | `General` | The name of an installed registry, or the path of a registry's directory, whose records give the commits of releases. | ## The code diff --git a/ReleaseFlow/src/ReleaseFlow.jl b/ReleaseFlow/src/ReleaseFlow.jl index 81bb937..f68f526 100644 --- a/ReleaseFlow/src/ReleaseFlow.jl +++ b/ReleaseFlow/src/ReleaseFlow.jl @@ -30,11 +30,13 @@ Inputs are environment variables, so that branch names never reach a shell, and - `RELEASEFLOW_SKIP_CHANGELOG`, `true` where the pull request has the label `RELEASEFLOW_SKIP_LABEL`, `skip changelog` unless given, for `entry`; - `RELEASEFLOW_CHECK_LINKS`, whether the changelog's comparison links are checked, `true` unless given; - `RELEASEFLOW_PROJECT`, the package's directory, relative to the repository's root, for a package in a subdirectory; the root unless given; +- `RELEASEFLOW_KIND`, `RELEASEFLOW_STEP`, `RELEASEFLOW_DATE` and `RELEASEFLOW_PIN_ACTION`, for `prepare`: the kind of release, `release` or `hotfix`, its step, `auto`, `patch`, `minor` or `major`, its date, today unless given, and an action, such as `BioJulia/Workflows`, whose `uses: …@vX.Y.Z` pins in `.github/workflows/` take the new version; - `RELEASEFLOW_REGISTRY`, the name of an installed registry, or the path of a registry's directory, `General` unless given. The base's files are read with `git show`, and a base without them, as before the first release, has no version and no changelog. """ module ReleaseFlow +using Dates using Pkg using TOML using UUIDs @@ -55,6 +57,7 @@ include("checks/changelog.jl") include("checks/entry.jl") include("checks/is_release.jl") include("checks/breaking.jl") +include("checks/prepare.jl") const USAGE = "usage: RELEASEFLOW_MODEL=$(join(sort(collect(keys(MODELS))), '|')) julia -m ReleaseFlow $(join(sort(collect(keys(CHECKS))), '|'))" diff --git a/ReleaseFlow/src/changelog.jl b/ReleaseFlow/src/changelog.jl index 57df964..0309c3a 100644 --- a/ReleaseFlow/src/changelog.jl +++ b/ReleaseFlow/src/changelog.jl @@ -59,6 +59,17 @@ entries(section::Section) = filter(is_entry, section.lines) unreleased_entries(sections) = (index = unreleased_index(sections); index === nothing ? String[] : entries(sections[index])) +# The text of the section of `version` in a changelog's `text`, without its heading, or `nothing` where it has none. +# A link's definition, such as `[0.2.0]: https://…`, which the last section's lines take in. +is_link(line) = occursin(r"^\[[^\]]+\]:\s*\S+", line) + +function section_text(text, version) + parsed = sections(text) + index = findfirst(section -> is_version(section, version), parsed) + index === nothing && return nothing + return String(strip(join(filter(!is_link, parsed[index].lines), '\n'))) +end + # The entries of the subsection `title`, such as `### Breaking`, of `section`. function subsection_entries(section::Section, title) inside = false diff --git a/ReleaseFlow/src/checks/checks.jl b/ReleaseFlow/src/checks/checks.jl index d402051..df87a75 100644 --- a/ReleaseFlow/src/checks/checks.jl +++ b/ReleaseFlow/src/checks/checks.jl @@ -9,8 +9,9 @@ struct ChangelogCheck <: Check end struct BreakingCheck <: Check end struct EntryCheck <: Check end struct IsReleaseCheck <: Check end +struct PrepareCheck <: Check end const CHECKS = Dict( "source" => SourceCheck(), "version" => VersionCheck(), "changelog" => ChangelogCheck(), - "breaking" => BreakingCheck(), "entry" => EntryCheck(), "is-release" => IsReleaseCheck(), + "breaking" => BreakingCheck(), "entry" => EntryCheck(), "is-release" => IsReleaseCheck(), "prepare" => PrepareCheck(), ) diff --git a/ReleaseFlow/src/checks/is_release.jl b/ReleaseFlow/src/checks/is_release.jl index f3e9392..dd334fa 100644 --- a/ReleaseFlow/src/checks/is_release.jl +++ b/ReleaseFlow/src/checks/is_release.jl @@ -5,6 +5,6 @@ function check(::IsReleaseCheck, settings::Settings) release = is_release(settings) println(release ? "The pull request changes the version to $(head_version(settings)), so it's a release." : "The pull request keeps the version, so it isn't a release.") - isempty(settings.outputs) || open(io -> println(io, "release=$(release)"), settings.outputs, "a") + output(settings, "release", string(release)) return end diff --git a/ReleaseFlow/src/checks/prepare.jl b/ReleaseFlow/src/checks/prepare.jl new file mode 100644 index 0000000..128b81b --- /dev/null +++ b/ReleaseFlow/src/checks/prepare.jl @@ -0,0 +1,135 @@ +# Prepare +# ------- +# +# Prepare a release in a checkout of the branch that it starts from: set its version in Project.toml, give the changelog's unreleased entries its section, with its links, and set the pins of an action to it. +# The workflow then commits the changes on the branch that `branch` names, and opens its pull request into `base`. + +# A release or a hotfix, as `RELEASEFLOW_KIND` names it. +release_kind(settings::Settings) = settings.kind == "hotfix" ? Hotfix() : Release() + +kind_label(::Release) = "release" +kind_label(::Hotfix) = "hotfix" + +# Trunk releases from its main branch, and has no hotfixes. +allows(::Settings, ::Kind) = true +allows(settings::Settings{Trunk}, ::Hotfix) = (failure(settings, "Prepare", "The trunk model has no hotfixes; prepare a release, from the main branch."); false) + +# `auto`: a `### Breaking` subsection of the unreleased changes takes a breaking version, `### Added` the next minor version, and anything else the next patch. +function auto_step(unreleased) + unreleased === nothing && return "patch" + subsection_entries(unreleased, "Breaking") > 0 && return "breaking" + subsection_entries(unreleased, "Added") > 0 && return "minor" + return "patch" +end + +# The version that a release of `kind` takes after its baseline, or `nothing` where it can't take one. +function next_version(settings::Settings, ::Release, release::LastRelease, unreleased) + base = release.version + step = settings.step == "auto" ? auto_step(unreleased) : settings.step + step == "breaking" && return first(breaking_steps(base)) + step == "major" && return next_major(base) + step == "minor" && return next_minor(base) + return next_patch(base) +end + +next_version(::Settings, ::Hotfix, release::LastRelease, unreleased) = next_patch(release.version) + +# The initial release takes a standard first version: 0.1.0, unless the step asks for 1.0.0, or for 0.0.1. +next_version(settings::Settings, ::Release, ::InitialRelease, unreleased) = settings.step == "major" ? v"1.0.0" : settings.step == "patch" ? v"0.0.1" : v"0.1.0" + +next_version(settings::Settings, ::Hotfix, ::InitialRelease, unreleased) = (failure(settings, "Prepare", "There's no release to fix, so a hotfix can't be the initial release; prepare a release."); nothing) + +release_branch(kind::Kind, version) = "$(kind_label(kind))/v$(version)" + +# The link of a version's section: the comparison with the last release's tag, or the initial release's own tag. +version_link(url, version, release::LastRelease) = "$(url)/compare/v$(release.version)...v$(version)" +version_link(url, version, ::InitialRelease) = "$(url)/releases/tag/v$(version)" + +function set_version!(settings::Settings, version) + path = joinpath(package_directory(settings), "Project.toml") + text = read(path, String) + pattern = r"^version\s*=.*$"m + occursin(pattern, text) || (failure(settings, "Prepare", "$(package_path(settings, "Project.toml")) has no version."); return) + write(path, replace(text, pattern => "version = \"$(version)\""; count = 1)) + return +end + +# Give the unreleased entries the section of `version`, under the unreleased heading, which stays, empty; without one, the section goes before the newest version's. +# The links follow: the unreleased changes compare with the new version's tag, and the new version with the last release's. +function add_section!(settings::Settings, version, release::Baseline) + path = joinpath(package_directory(settings), "CHANGELOG.md") + isfile(path) || (failure(settings, "Prepare", "There's no $(package_path(settings, "CHANGELOG.md"))."); return) + lines = String.(split(rstrip(read(path, String)), '\n')) + heading = "## [$(version)] - $(settings.date)" + unreleased = findfirst(line -> occursin(UNRELEASED, line), lines) + if unreleased !== nothing + splice!(lines, (unreleased + 1):unreleased, ["", heading]) + else + newest = findfirst(line -> heading_of(line) isa Released, lines) + at = something(newest, length(lines) + 1) + splice!(lines, at:(at - 1), newest === nothing ? ["", heading] : [heading, ""]) + end + url = repository_url(settings) + if settings.check_links && url !== nothing + link = "[$(version)]: $(version_link(url, version, release))" + defined = findfirst(line -> occursin(r"^\[unreleased\]:"i, line), lines) + if unreleased !== nothing && defined !== nothing + lines[defined] = "[Unreleased]: $(url)/compare/v$(version)...HEAD" + insert!(lines, defined + 1, link) + elseif unreleased !== nothing + first_link = findfirst(is_link, lines) + new = ["[Unreleased]: $(url)/compare/v$(version)...HEAD", link] + first_link === nothing ? append!(lines, ["", new...]) : splice!(lines, first_link:(first_link - 1), new) + else + first_link = findfirst(is_link, lines) + first_link === nothing ? append!(lines, ["", link]) : insert!(lines, first_link, link) + end + end + write(path, join(lines, '\n') * '\n') + return +end + +heading_of(line) = startswith(line, "## ") ? heading(line) : nothing + +# Set each pin of the action `settings.pin_action` in the workflows, `uses: @vA.B.C`, to the new version; a major tag, such as `@v1`, stays. +function set_pins!(settings::Settings, version) + isempty(settings.pin_action) && return 0 + directory = joinpath(settings.root, ".github", "workflows") + isdir(directory) || return 0 + pattern = Regex("(uses:\\s*" * replace(settings.pin_action, "." => "\\.") * "(?:/[^@\\s]*)?@)v\\d+\\.\\d+\\.\\d+") + changed = 0 + for file in readdir(directory; join = true) + endswith(file, ".yml") || endswith(file, ".yaml") || continue + text = read(file, String) + new = replace(text, pattern => SubstitutionString("\\1v$(version)")) + new == text && continue + write(file, new) + changed += 1 + end + return changed +end + +function check(::PrepareCheck, settings::Settings) + settings.kind in ("release", "hotfix") || return failure(settings, "Prepare", "RELEASEFLOW_KIND is `release` or `hotfix`, not `$(settings.kind)`.") + settings.step in ("auto", "patch", "minor", "major") || return failure(settings, "Prepare", "RELEASEFLOW_STEP is `auto`, `patch`, `minor` or `major`, not `$(settings.step)`.") + kind = release_kind(settings) + allows(settings, kind) || return + release = last_release(settings) + text = head_changelog(settings) + text === nothing && return + parsed = sections(text) + index = unreleased_index(parsed) + version = next_version(settings, kind, release, index === nothing ? nothing : parsed[index]) + (version === nothing || !isempty(settings.errors)) && return + set_version!(settings, version) + add_section!(settings, version, release) + pins = set_pins!(settings, version) + isempty(settings.errors) || return + branch = release_branch(kind, version) + println("Prepared the $(kind_label(kind)) $(version), after $(label(release)), for $(branch) into $(settings.main_branch)$(pins > 0 ? ", with the pins of $(settings.pin_action) in $(pins) workflows" : "").") + output(settings, "version", string(version)) + output(settings, "branch", branch) + output(settings, "base", settings.main_branch) + output(settings, "notes", something(section_text(read(joinpath(package_directory(settings), "CHANGELOG.md"), String), version), "")) + return +end diff --git a/ReleaseFlow/src/reporting.jl b/ReleaseFlow/src/reporting.jl index 1e6fa4d..d34ef81 100644 --- a/ReleaseFlow/src/reporting.jl +++ b/ReleaseFlow/src/reporting.jl @@ -8,6 +8,20 @@ function failure(settings::Settings, title, message) return end +# Write the step's output `name`, for GitHub Actions; a value of several lines takes a delimiter that it doesn't contain. +function output(settings::Settings, name, value) + isempty(settings.outputs) && return + open(settings.outputs, "a") do io + if occursin('\n', value) + delimiter = "END_OF_$(uppercase(name))_$(rand(UInt32))" + print(io, name, "<<", delimiter, '\n', value, '\n', delimiter, '\n') + else + print(io, name, "=", value, '\n') + end + end + return +end + note(message) = println("::notice::", replace(message, "\n" => "%0A")) # Append Markdown to the job's summary, which GitHub Actions shows on the run's page. diff --git a/ReleaseFlow/src/settings.jl b/ReleaseFlow/src/settings.jl index 494f5cc..5b20c48 100644 --- a/ReleaseFlow/src/settings.jl +++ b/ReleaseFlow/src/settings.jl @@ -22,6 +22,11 @@ struct Settings{M <: Model} skip_changelog::Bool check_links::Bool registry::String + # For `prepare`: the kind of release, `release` or `hotfix`, its step, `auto`, `patch`, `minor` or `major`, its date, and the action whose pins it sets. + kind::String + step::String + date::String + pin_action::String # Where GitHub Actions names the repository, takes a step's outputs and shows a job's summary; empty outside it. repository::String outputs::String @@ -51,6 +56,10 @@ function Settings(environment = ENV; root = pwd()) lowercase(input(environment, "RELEASEFLOW_SKIP_CHANGELOG", "false")) == "true", lowercase(input(environment, "RELEASEFLOW_CHECK_LINKS", "true")) == "true", input(environment, "RELEASEFLOW_REGISTRY", "General"), + lowercase(input(environment, "RELEASEFLOW_KIND", "release")), + lowercase(input(environment, "RELEASEFLOW_STEP", "auto")), + input(environment, "RELEASEFLOW_DATE", string(Date(now(UTC)))), + input(environment, "RELEASEFLOW_PIN_ACTION", ""), input(environment, "GITHUB_REPOSITORY", ""), input(environment, "GITHUB_OUTPUT", ""), input(environment, "GITHUB_STEP_SUMMARY", ""), diff --git a/ReleaseFlow/test/prepare.jl b/ReleaseFlow/test/prepare.jl new file mode 100644 index 0000000..6130eb5 --- /dev/null +++ b/ReleaseFlow/test/prepare.jl @@ -0,0 +1,130 @@ +# Preparing a release +# ------------------- + +# The outputs that a check wrote, from the file of GitHub Actions' outputs. +function outputs(path) + result = Dict{String, String}() + lines = readlines(path) + index = 1 + while index <= length(lines) + line = lines[index] + m = match(r"^([\w-]+)<<(.+)$", line) + if m === nothing + name, value = split(line, '='; limit = 2) + result[name] = value + else + stop = findnext(==(m[2]), lines, index + 1) + result[m[1]] = join(lines[(index + 1):(stop - 1)], '\n') + index = stop + end + index += 1 + end + return result +end + +# Prepare a release in a checkout of `from`, commit it on the branch that it names, and return its outputs and result. +function prepare!(directory, from; variables...) + git(directory, "checkout", "-q", "--detach", from) + path = tempname() + result = check(directory, "prepare"; GITHUB_OUTPUT = path, RELEASEFLOW_DATE = "2026-10-20", variables...) + prepared = isfile(path) ? outputs(path) : Dict{String, String}() + if result.code == 0 + git(directory, "checkout", "-q", "-B", prepared["branch"]) + git(directory, "commit", "-q", "-am", "Prepare $(prepared["version"])") + end + return (; result, prepared) +end + +@testset "Prepare" begin + @testset "units" begin + R = ReleaseFlow + settings(step) = R.Settings(Dict("RELEASEFLOW_STEP" => step)) + log(entries...) = only(filter(section -> section isa R.Section{R.Unreleased}, R.sections(join(["## [Unreleased]", "", entries...], '\n')))) + @test R.auto_step(log("- A fix.")) == "patch" + @test R.auto_step(log("### Added", "", "- A feature.")) == "minor" + @test R.auto_step(log("### Breaking", "", "- A removal.", "", "### Added", "", "- A feature.")) == "breaking" + @test R.auto_step(nothing) == "patch" + last(version) = R.LastRelease{R.MainBranch}(version, "origin/master") + @test R.next_version(settings("auto"), R.Release(), last(v"0.1.3"), log("### Breaking", "", "- x")) == v"0.2.0" + @test R.next_version(settings("auto"), R.Release(), last(v"1.2.3"), log("### Breaking", "", "- x")) == v"2.0.0" + @test R.next_version(settings("auto"), R.Release(), last(v"1.2.3"), log("### Added", "", "- x")) == v"1.3.0" + @test R.next_version(settings("major"), R.Release(), last(v"1.2.3"), nothing) == v"2.0.0" + @test R.next_version(settings("minor"), R.Hotfix(), last(v"1.2.3"), nothing) == v"1.2.4" + @test R.next_version(settings("auto"), R.Release(), R.InitialRelease(), nothing) == v"0.1.0" + @test R.next_version(settings("major"), R.Release(), R.InitialRelease(), nothing) == v"1.0.0" + @test R.next_version(settings("patch"), R.Release(), R.InitialRelease(), nothing) == v"0.0.1" + end + + @testset "gitflow" begin + directory, released, feature = gitflow_repository() + # A release from develop: the next patch, as the unreleased entries add nothing, and break nothing. + prepared = prepare!(directory, feature) + @test passes(prepared.result) + @test prepared.prepared["version"] == "0.1.1" && prepared.prepared["branch"] == "release/v0.1.1" && prepared.prepared["base"] == "master" + @test prepared.prepared["notes"] == "- A feature." + for check_name in ("source", "version", "changelog") + @test passes(check(directory, check_name; RELEASEFLOW_BASE_BRANCH = "master", RELEASEFLOW_HEAD_REF = "release/v0.1.1", RELEASEFLOW_HEAD_SHA = "release/v0.1.1")) + end + text = read(joinpath(directory, "CHANGELOG.md"), String) + @test occursin("## [Unreleased]\n\n## [0.1.1] - 2026-10-20\n\n- A feature.", text) + @test occursin("[Unreleased]: $(URL)/compare/v0.1.1...HEAD\n[0.1.1]: $(URL)/compare/v0.1.0...v0.1.1\n[0.1.0]: $(URL)/releases/tag/v0.1.0", text) + @test occursin("version = \"0.1.1\"", read(joinpath(directory, "Project.toml"), String)) + # `### Added` takes the next minor version, and a step can be asked for. + branch!(directory, "develop", feature) + added = commit!(directory, "Added", "CHANGELOG.md" => replace(changelog(unreleased = ["A feature."], released = ["0.1.0" => ["First."]]), "## [Unreleased]\n\n" => "## [Unreleased]\n\n### Added\n\n")) + @test prepare!(directory, added).prepared["version"] == "0.2.0" + @test prepare!(directory, feature; RELEASEFLOW_STEP = "major").prepared["version"] == "1.0.0" + # A hotfix from master, the next patch, whose empty section waits for the fix's entry. + hotfix = prepare!(directory, released; RELEASEFLOW_KIND = "hotfix") + @test passes(hotfix.result) && hotfix.prepared["branch"] == "hotfix/v0.1.1" + @test passes(check(directory, "version"; RELEASEFLOW_HEAD_REF = "hotfix/v0.1.1", RELEASEFLOW_HEAD_SHA = "hotfix/v0.1.1")) + @test fails(check(directory, "changelog"; RELEASEFLOW_HEAD_REF = "hotfix/v0.1.1"), "The section of 0.1.1 in CHANGELOG.md has no entries") + # A wrong kind or step is an error. + @test fails(prepare!(directory, feature; RELEASEFLOW_KIND = "feature").result, "RELEASEFLOW_KIND is `release` or `hotfix`") + @test fails(prepare!(directory, feature; RELEASEFLOW_STEP = "huge").result, "RELEASEFLOW_STEP is `auto`, `patch`, `minor` or `major`") + end + + @testset "pins" begin + directory, released, feature = gitflow_repository() + branch!(directory, "develop", feature) + pinned = commit!( + directory, "Workflows", ".github/workflows/a.yml" => "jobs:\n a:\n steps:\n - uses: Example/Toy.jl@v0.1.0\n - uses: Example/Toy.jl/.github/workflows/b.yml@v0.1.0\n", + ".github/workflows/c.yml" => "jobs:\n c:\n uses: Example/Toy.jl/.github/workflows/b.yml@v1\n steps:\n - uses: Other/Action@v0.1.0\n", + ) + @test passes(prepare!(directory, pinned; RELEASEFLOW_PIN_ACTION = "Example/Toy.jl").result) + @test read(joinpath(directory, ".github", "workflows", "a.yml"), String) == "jobs:\n a:\n steps:\n - uses: Example/Toy.jl@v0.1.1\n - uses: Example/Toy.jl/.github/workflows/b.yml@v0.1.1\n" + # A major tag, and other actions, stay. + @test read(joinpath(directory, ".github", "workflows", "c.yml"), String) == "jobs:\n c:\n uses: Example/Toy.jl/.github/workflows/b.yml@v1\n steps:\n - uses: Other/Action@v0.1.0\n" + end + + @testset "initial release" begin + directory, start, package = initial_repository() + prepared = prepare!(directory, package) + @test prepared.prepared["version"] == "0.1.0" + for check_name in ("version", "changelog") + @test passes(check(directory, check_name; RELEASEFLOW_HEAD_REF = "release/v0.1.0", RELEASEFLOW_HEAD_SHA = "release/v0.1.0")) + end + @test occursin("[0.1.0]: $(URL)/releases/tag/v0.1.0", read(joinpath(directory, "CHANGELOG.md"), String)) + @test fails(prepare!(directory, package; RELEASEFLOW_KIND = "hotfix").result, "a hotfix can't be the initial release") + end + + @testset "OneFlow and trunk" begin + directory, released, feature, scratch = oneflow_repository() + model = (RELEASEFLOW_MODEL = "oneflow", RELEASEFLOW_MAIN_BRANCH = "main", RELEASEFLOW_BASE_BRANCH = "main", RELEASEFLOW_REGISTRY = scratch) + @test prepare!(directory, feature; model...).prepared["branch"] == "release/v0.1.1" + for check_name in ("source", "version", "changelog") + @test passes(check(directory, check_name; model..., RELEASEFLOW_HEAD_REF = "release/v0.1.1", RELEASEFLOW_HEAD_SHA = "release/v0.1.1")) + end + # A OneFlow hotfix starts from the last release's tag. + @test prepare!(directory, "v0.1.0"; model..., RELEASEFLOW_KIND = "hotfix").prepared["branch"] == "hotfix/v0.1.1" + @test passes(check(directory, "source"; model..., RELEASEFLOW_HEAD_REF = "hotfix/v0.1.1", RELEASEFLOW_HEAD_SHA = "hotfix/v0.1.1")) + trunk = (RELEASEFLOW_MODEL = "trunk", RELEASEFLOW_MAIN_BRANCH = "main", RELEASEFLOW_BASE_BRANCH = "main", RELEASEFLOW_REGISTRY = scratch) + @test prepare!(directory, feature; trunk...).prepared["branch"] == "release/v0.1.1" + @test fails(prepare!(directory, feature; trunk..., RELEASEFLOW_KIND = "hotfix").result, "The trunk model has no hotfixes") + # A changelog without an unreleased section, as FASTX.jl keeps it, takes the new section before the newest version's. + branch!(directory, "plain", released) + plain = commit!(directory, "Plain", "CHANGELOG.md" => changelog(released = ["0.1.0" => ["First."]], heading = nothing, bullet = "*", links = false)) + @test passes(prepare!(directory, plain; trunk..., RELEASEFLOW_CHECK_LINKS = false).result) + @test startswith(read(joinpath(directory, "CHANGELOG.md"), String), "# Changelog\n\n## [0.1.1] - 2026-10-20\n\n## [0.1.0]") + end +end diff --git a/ReleaseFlow/test/release_workflow.jl b/ReleaseFlow/test/release_workflow.jl new file mode 100644 index 0000000..134cd23 --- /dev/null +++ b/ReleaseFlow/test/release_workflow.jl @@ -0,0 +1,60 @@ +# The release workflow +# -------------------- + +@testset "Release workflow" begin + directory = mktempdir() + script = joinpath(directory, "step.sh") + write(script, workflow_step("release.yml", "Push the branch and open the pull request")) + # A stand-in for `gh`: it logs its calls, and gives the app's bot an ID. + bin = mkpath(joinpath(directory, "bin")) + write( + joinpath(bin, "gh"), raw""" + #!/bin/bash + echo "gh $*" >> "$LOG" + case "$1 $2" in + "api users/"*) echo 4242;; + esac + """ + ) + chmod(joinpath(bin, "gh"), 0o755) + origin = joinpath(directory, "origin.git") + run(pipeline(`git init -q --bare $(origin)`)) + work = repository() + git(work, "remote", "set-url", "origin", origin) + commit!(work, "Start", "Project.toml" => project("0.1.0"), "CHANGELOG.md" => changelog(unreleased = ["A feature."], released = ["0.1.0" => ["First."]])) + git(work, "push", "-q", "origin", "HEAD:develop") + log = joinpath(directory, "log") + + # The prepared changes, uncommitted, as the action leaves them. + function step(; kind = "release", version = "0.1.1", app = "toy-release", branch = "$(kind)/v$(version)") + git(work, "checkout", "-q", "-f", "--detach", "origin/develop") + write(joinpath(work, "Project.toml"), project(version)) + write(log, "") + environment = Dict( + "PATH" => "$(bin):$(ENV["PATH"])", "LOG" => log, "GH_TOKEN" => "token", "APP_SLUG" => app, "REPOSITORY" => "Example/Toy.jl", + "RELEASEFLOW_KIND" => kind, "VERSION" => version, "BRANCH" => branch, "BASE" => "master", "NOTES" => "- A feature.", + ) + output = IOBuffer() + process = run(pipeline(Cmd(addenv(`bash $(script)`, environment); dir = work, ignorestatus = true); stdout = output, stderr = output)) + return (code = process.exitcode, output = String(take!(output)), calls = readlines(log)) + end + pushed(branch) = git(origin, "log", "-1", "--format=%an <%ae> %s", "refs/heads/$(branch)") + + # A release: the branch is pushed as the app's bot, and its pull request opened into the base. + result = step() + @test result.code == 0 + @test pushed("release/v0.1.1") == "toy-release[bot] <4242+toy-release[bot]@users.noreply.github.com> Release 0.1.1" + create = only(filter(call -> startswith(call, "gh pr create"), result.calls)) + @test occursin("--base master --head release/v0.1.1 --title Release 0.1.1", create) + # The body spans lines of the log, so the flag ends the last of them. + @test endswith(rstrip(read(log, String)), "--draft=false") + @test occursin("- A feature.", read(log, String)) + # A branch that exists is an earlier preparation, which isn't overwritten. + result = step() + @test result.code == 1 && occursin("The branch release/v0.1.1 exists already", result.output) + # A hotfix's pull request is a draft, until the fix is pushed; with a personal token, the commit is GitHub Actions'. + result = step(; kind = "hotfix", version = "0.1.2", app = "") + @test result.code == 0 + @test pushed("hotfix/v0.1.2") == "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Hotfix 0.1.2" + @test endswith(rstrip(read(log, String)), "--draft=true") +end diff --git a/ReleaseFlow/test/runtests.jl b/ReleaseFlow/test/runtests.jl index 7bf9781..590e8cf 100644 --- a/ReleaseFlow/test/runtests.jl +++ b/ReleaseFlow/test/runtests.jl @@ -13,7 +13,9 @@ include("gitflow.jl") include("oneflow.jl") include("trunk.jl") include("initial.jl") +include("prepare.jl") include("subdirectory.jl") include("back_merge.jl") include("tag.jl") include("dependents.jl") +include("release_workflow.jl") diff --git a/action.yml b/action.yml index 7e5e592..4b2aa6a 100644 --- a/action.yml +++ b/action.yml @@ -4,7 +4,7 @@ description: Checks a pull request of a Julia package under gitflow, OneFlow or # Every input reaches the script through the environment, never the shell, so that a crafted branch name can't run commands. inputs: check: - description: The check to run, `source`, `version`, `changelog`, `breaking`, `entry` or `is-release`. + description: The check to run, `source`, `version`, `changelog`, `breaking`, `entry` or `is-release`, or `prepare`, which prepares a release. required: true model: description: The branching model, `gitflow`, `oneflow` or `trunk`. @@ -39,11 +39,35 @@ inputs: registry: description: The registry whose records give the commits of releases. default: General + kind: + description: For `prepare`, the kind of release, `release` or `hotfix`. + default: release + step: + description: For `prepare`, the step of the version, `auto`, `patch`, `minor` or `major`. + default: auto + date: + description: For `prepare`, the date of the release's section; today, in UTC, if empty. + default: '' + pin-action: + description: 'For `prepare`, an action, such as `BioJulia/Workflows`, whose `uses: …@vX.Y.Z` pins in the workflows take the new version.' + default: '' outputs: release: description: For the `is-release` check, whether the pull request is a release, `true` or `false`. value: ${{ steps.check.outputs.release }} + version: + description: For `prepare`, the version of the prepared release. + value: ${{ steps.check.outputs.version }} + branch: + description: For `prepare`, the branch of the prepared release, `release/vX.Y.Z` or `hotfix/vX.Y.Z`. + value: ${{ steps.check.outputs.branch }} + base: + description: For `prepare`, the branch that the prepared release merges into. + value: ${{ steps.check.outputs.base }} + notes: + description: For `prepare`, the prepared release's section of the changelog. + value: ${{ steps.check.outputs.notes }} runs: using: composite @@ -67,6 +91,10 @@ runs: RELEASEFLOW_CHECK_LINKS: ${{ inputs.check-links }} RELEASEFLOW_PROJECT: ${{ inputs.project }} RELEASEFLOW_REGISTRY: ${{ inputs.registry }} + RELEASEFLOW_KIND: ${{ inputs.kind }} + RELEASEFLOW_STEP: ${{ inputs.step }} + RELEASEFLOW_DATE: ${{ inputs.date }} + RELEASEFLOW_PIN_ACTION: ${{ inputs.pin-action }} run: | export RELEASEFLOW_BASE_REF="${BASE_REF_INPUT:-origin/$RELEASEFLOW_BASE_BRANCH}" export RELEASEFLOW_DEVELOP_REF="origin/$RELEASEFLOW_DEVELOP_BRANCH"