From b586e2e4e9e43bf7e8e090a10324243d0880820a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20O=E2=80=99Mara?= Date: Tue, 20 Oct 2026 12:59:52 +1100 Subject: [PATCH] Move the major tags to the newest releases The reusable workflow tag.yml moves each major tag, such as v1, to the newest release of its major version, vX.Y.Z, by version, for a repository that callers follow by its major tag, such as one of reusable workflows. It follows whatever version tags exist, whoever made them, TagBot or a maintainer, and whenever it runs: on version tags pushed, on TagBot's completion, whose GITHUB_TOKEN tags start no workflow, daily, and by hand. A major tag already at its newest release stays, an older version's tag doesn't move it, pre-releases are left out, and annotated tags count at their commits. It moves the tags with the ReleaseFlow app's token, or else RELEASEFLOW_TOKEN, or else the job's own token. This repository's TagBot now tags releases vX.Y.Z, with `tag_prefix: NO_PREFIX` and the ReleaseFlow app's token, in place of ReleaseFlow-vX.Y.Z, and Self-Tag.yml moves v1 to the newest; so callers following v1 get a release once ReleaseFlow's is registered and tagged, never one whose tag doesn't exist yet. The README describes the workflow beside TagBot, the environment's rules for the default branch and tags, and the release's steps. The tests run the step in a repository with version tags, with a stand-in for `gh`. --- .github/workflows/Self-Tag.yml | 26 +++++++++ .github/workflows/TagBot.yml | 22 ++++++- .github/workflows/tag.yml | 101 +++++++++++++++++++++++++++++++++ README.md | 44 ++++++++++++-- ReleaseFlow/CHANGELOG.md | 5 ++ ReleaseFlow/test/helpers.jl | 14 +++++ ReleaseFlow/test/runtests.jl | 1 + ReleaseFlow/test/tag.jl | 58 +++++++++++++++++++ 8 files changed, 263 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/Self-Tag.yml create mode 100644 .github/workflows/tag.yml create mode 100644 ReleaseFlow/test/tag.jl diff --git a/.github/workflows/Self-Tag.yml b/.github/workflows/Self-Tag.yml new file mode 100644 index 0000000..6163e5d --- /dev/null +++ b/.github/workflows/Self-Tag.yml @@ -0,0 +1,26 @@ +# This repository's own caller of the reusable tag.yml, which other repositories call as BioJulia/Workflows/.github/workflows/tag.yml@v1; the Self- files apply this repository's workflows to itself. +# TagBot tags each release vX.Y.Z once General registers ReleaseFlow; this moves v1, which callers follow, to the newest of them, so callers get a release once it's registered and tagged. +# workflow_run, schedule and workflow_dispatch run this file from the default branch, develop. +name: "Self: Tag" + +on: + push: + tags: ['v[0-9]+.[0-9]+.[0-9]+'] + workflow_run: + workflows: [TagBot] + types: [completed] + schedule: + - cron: '17 4 * * *' + workflow_dispatch: + +permissions: {} + +jobs: + tag: + uses: ./.github/workflows/tag.yml + permissions: + contents: write + 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/TagBot.yml b/.github/workflows/TagBot.yml index 7c82cbb..f44596a 100644 --- a/.github/workflows/TagBot.yml +++ b/.github/workflows/TagBot.yml @@ -1,5 +1,5 @@ -# TagBot: tags the releases of ReleaseFlow that General registers, from the subdirectory ReleaseFlow/, as ReleaseFlow-vX.Y.Z. -# The workflows' own releases, vX.Y.Z, are tagged by hand; see the README. +# TagBot: tags each release of ReleaseFlow, registered in General from the subdirectory ReleaseFlow/, vX.Y.Z, the version of the workflows too, with its GitHub release, once General registers it. +# Self-Tag.yml then moves v1 to the newest of them. name: TagBot on: @@ -15,8 +15,24 @@ jobs: TagBot: if: github.event_name == 'workflow_dispatch' || github.actor == 'JuliaTagBot' runs-on: ubuntu-latest + # The ReleaseFlow app's secrets, which the ruleset on `v*` tags lets through; the environment allows the default branch, where this runs. + environment: releaseflow 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 }} - uses: JuliaRegistries/TagBot@ffef3282bcacf1055e4d621a5c010959dfbe59ff # v1.25.13 with: - token: ${{ secrets.GITHUB_TOKEN }} + # The app's token, whose tags also start Self-Tag.yml by their push; GITHUB_TOKEN otherwise, whose tags Self-Tag.yml follows when TagBot's run completes. + token: ${{ steps.app.outputs.token || secrets.GITHUB_TOKEN }} subdir: ReleaseFlow + # vX.Y.Z, at which the workflows call the action, rather than ReleaseFlow-vX.Y.Z, as for a package in a subdirectory. + tag_prefix: NO_PREFIX diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..6584577 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,101 @@ +# Tag: move each major tag, such as v1, to the newest release of its major version, vX.Y.Z, for a repository that callers follow by its major tag, such as one of GitHub Actions or reusable workflows; BioJulia/Workflows moves its own with it. +# It moves major tags only: TagBot tags each release vX.Y.Z, at the commit that the registry records, once the registry merges it, with `tag_prefix: NO_PREFIX` for a package in a subdirectory; a maintainer may also push a version tag by hand. +# So a major tag only ever follows a version tag that exists, and the job does the same whenever it runs: a major tag already at its newest release stays, a version tag older than the newest doesn't move it, and pre-releases, such as v2.0.0-rc1, are left out. +# Called from the repository's own workflow, on whatever makes version tags: +# +# on: +# push: +# tags: ['v[0-9]+.[0-9]+.[0-9]+'] # version tags pushed by hand, or by TagBot with an SSH key +# workflow_run: +# workflows: [TagBot] # TagBot's tags, made with GITHUB_TOKEN, start no workflow +# types: [completed] +# schedule: +# - cron: '17 4 * * *' # a daily fallback +# workflow_dispatch: +# permissions: {} +# jobs: +# tag: +# uses: BioJulia/Workflows/.github/workflows/tag.yml@v1 +# permissions: +# contents: write +# secrets: +# RELEASEFLOW_APP_CLIENT_ID: ${{ secrets.RELEASEFLOW_APP_CLIENT_ID }} +# RELEASEFLOW_APP_PRIVATE_KEY: ${{ secrets.RELEASEFLOW_APP_PRIVATE_KEY }} +# RELEASEFLOW_TOKEN: ${{ secrets.RELEASEFLOW_TOKEN }} +# +# Moving a major tag doesn't match the version tags' filter, so it doesn't start the workflow again. +# The tags are moved with the ReleaseFlow app's token, or else RELEASEFLOW_TOKEN, or else the job's own token, which a ruleset on `v*` tags must let through; the app can be on the ruleset's bypass list. +# The job runs in the deployment environment `environment`, `releaseflow` unless given, which keeps those secrets; these triggers run on the default branch or on a tag, so the environment must allow them, or `environment: ''` runs the job without one. +name: "Reusable: Tag" + +on: + workflow_call: + inputs: + environment: + description: The deployment environment that holds the secrets, or empty for none. + type: string + default: releaseflow + secrets: + RELEASEFLOW_APP_CLIENT_ID: + description: The client ID of the ReleaseFlow app, which moves the major tags. + 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: + tag: + name: Tag + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + permissions: + contents: write + 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 }} + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - name: Move the major tags + env: + GH_TOKEN: ${{ steps.app.outputs.token || secrets.RELEASEFLOW_TOKEN || github.token }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + # The version tags, vX.Y.Z only, oldest first. + versions=$(git tag --list 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V || true) + if [ -z "$versions" ]; then + echo "There's no version tag vX.Y.Z, so there's no major tag to move." + exit 0 + fi + for major in $(echo "$versions" | sed -E 's/^(v[0-9]+)\..*$/\1/' | sort -u); do + newest=$(echo "$versions" | grep -E "^$major\." | tail -n 1) + # The commit of a tag, also of an annotated one. + target=$(git rev-parse "$newest^{commit}") + current=$(git rev-parse --verify --quiet "refs/tags/$major^{commit}" || true) + if [ "$current" = "$target" ]; then + echo "$major is at $newest, $target, already." + elif [ -n "$current" ]; then + gh api --method PATCH "repos/$REPOSITORY/git/refs/tags/$major" -f sha="$target" -F force=true > /dev/null + echo "Moved $major to $newest, $target." + else + gh api "repos/$REPOSITORY/git/refs" -f ref="refs/tags/$major" -f sha="$target" > /dev/null + echo "Tagged $newest's commit, $target, $major." + fi + done diff --git a/README.md b/README.md index aa9ae17..d898e0a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Reusable GitHub Actions workflows for Julia packages: checks that a pull request The repository holds two things: - **the workflows**, in [`.github/workflows/`](.github/workflows), with the composite action, [`action.yml`](action.yml), that they call, tagged `vX.Y.Z`; -- **the checks**, a Julia package, [ReleaseFlow](ReleaseFlow), registered in General from this repository's `ReleaseFlow/` directory, and tagged `ReleaseFlow-vX.Y.Z`. +- **the checks**, a Julia package, [ReleaseFlow](ReleaseFlow), registered in General from this repository's `ReleaseFlow/` directory, whose version, and tag, the workflows share. Both share one version, in `ReleaseFlow/Project.toml`, and one changelog, [`ReleaseFlow/CHANGELOG.md`](ReleaseFlow/CHANGELOG.md). @@ -77,6 +77,38 @@ jobs: contents: read ``` +And, for a repository that callers follow by its major tag, such as one of GitHub Actions or reusable workflows, the tag workflow, in `.github/workflows/Tag.yml`, which moves each major tag, such as `v1`, to the newest release of its major version, `vX.Y.Z`: + +```yaml +name: Tag +on: + push: + tags: ['v[0-9]+.[0-9]+.[0-9]+'] # version tags pushed by hand, or by TagBot with an SSH key + workflow_run: + workflows: [TagBot] # TagBot's tags, made with GITHUB_TOKEN, start no workflow + types: [completed] + schedule: + - cron: '17 4 * * *' # a daily fallback + workflow_dispatch: +permissions: {} +jobs: + tag: + uses: BioJulia/Workflows/.github/workflows/tag.yml@v1 + permissions: + contents: write + 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 works alongside [TagBot](https://github.com/JuliaRegistries/TagBot), which tags each release `vX.Y.Z` once the registry records it, at the commit that the registry records, with its GitHub release, but never moves a major tag; for a package in a subdirectory, TagBot's `tag_prefix: NO_PREFIX` makes its tags `vX.Y.Z` rather than `-vX.Y.Z`. +The tag workflow follows whatever version tags exist, whenever they appear, including those that a maintainer pushes by hand: a major tag moves only to its major version's newest release, by version, so an older version's tag doesn't move it, pre-releases such as `v2.0.0-rc1` are left out, and a rerun changes nothing. +So callers of the major tag get a release once it's registered and tagged, and never a release whose tag doesn't exist yet. + +It moves the tags with the ReleaseFlow app's token, or else `RELEASEFLOW_TOKEN`, or else the job's own token; a ruleset on `v*` tags must let that token through, such as with the app on its bypass list. +Its triggers run on the default branch or on a tag, not on the main branch, so the `releaseflow` environment must allow them, as described in [The ReleaseFlow environment](#the-releaseflow-environment), or the caller passes `with: { environment: '' }` to run the job without an environment, with the job's own token. + A package can follow the major tag, `v1`, or pin a release, such as `v1.0.0`; Dependabot's `github-actions` updates keep a pinned release current. ### Inputs @@ -121,6 +153,7 @@ An environment's secrets reach only the jobs on the branches that the environmen 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. GitHub uses the environment's secrets in place of secrets of the same name that the caller passes, so the caller needs no change. @@ -143,6 +176,7 @@ One app serves every repository of the organization that uses the back-merge wor - 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. Metadata: Read-only is added by itself; leave every other permission at no access. 5. Under "Where can this GitHub App be installed?", choose "Only on this account", and choose "Create GitHub App". @@ -236,14 +270,14 @@ A release or hotfix branch: 3. moves the changelog's unreleased entries into a `## [X.Y.Z]` section, with its comparison link. Once it merges into `master`: -1. tag the merge commit `vX.Y.Z`, and move the major tag to it, with `git tag -f v1 vX.Y.Z` and `git push -f origin v1`; -2. comment `@JuliaRegistrator register subdir=ReleaseFlow` on the commit, which registers ReleaseFlow in General from its subdirectory; -3. once General merges the registration, TagBot tags the commit `ReleaseFlow-vX.Y.Z`. +1. comment `@JuliaRegistrator register subdir=ReleaseFlow` on the commit, which registers ReleaseFlow in General from its subdirectory; +2. once General merges the registration, TagBot tags the commit `vX.Y.Z`, with its GitHub release, which the workflows' calls of the action name; +3. the Tag workflow, [`Self-Tag.yml`](.github/workflows/Self-Tag.yml), then moves the major tag, `v1`, to it, so callers get the release once it's registered and tagged. A breaking change to a workflow's inputs, jobs or check names, or to ReleaseFlow's public API, takes a new major version, and a new major tag, such as `v2`. Every package that calls the workflows runs what their tags point to, so: -- a ruleset on tags matching `v*` restricts who can create, move or delete them to the maintainers; +- a ruleset on tags matching `v*` restricts who can create, move or delete them to the maintainers and the ReleaseFlow app, on its bypass list, with whose token TagBot tags releases and the Tag workflow moves the major tag; - the workflows and the action pin the actions of other owners to commits, with their versions in comments, which Dependabot updates by pull request, labelled `skip changelog`; - `.github/CODEOWNERS` requires a maintainer's review of any change to the workflows or the action. diff --git a/ReleaseFlow/CHANGELOG.md b/ReleaseFlow/CHANGELOG.md index de930f7..4f3ea2c 100644 --- a/ReleaseFlow/CHANGELOG.md +++ b/ReleaseFlow/CHANGELOG.md @@ -6,6 +6,11 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Added + +- The reusable workflow `tag.yml`, which moves each major tag, such as `v1`, to the newest release of its major version, `vX.Y.Z`, whichever made the tag, TagBot or a maintainer, and whenever, for a repository that callers follow by its major tag; it never moves a major tag backwards, and leaves pre-releases out. +- 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. + ## [1.0.1] - 2026-10-20 - The breaking check counts a name that still resolves as provided, such as an old name kept with `Base.@deprecate_binding`, which Julia's `names` leaves out; a rename through a deprecated binding is no longer reported as a removal. diff --git a/ReleaseFlow/test/helpers.jl b/ReleaseFlow/test/helpers.jl index 1b55e98..d431192 100644 --- a/ReleaseFlow/test/helpers.jl +++ b/ReleaseFlow/test/helpers.jl @@ -143,3 +143,17 @@ end passes(result) = result.code == 0 fails(result, message) = result.code == 1 && occursin(message, result.output) + +# The shell of the step `name` of the workflow `file`: the block after its `run: |`, without its indentation. +function workflow_step(file, name) + lines = readlines(normpath(joinpath(@__DIR__, "..", "..", ".github", "workflows", file))) + named = findfirst(line -> strip(line) == "- name: $(name)", lines) + start = findnext(line -> endswith(line, "run: |"), lines, named) + indentation = length(match(r"^ *", lines[start + 1]).match) + block = String[] + for line in lines[(start + 1):end] + !isempty(strip(line)) && length(match(r"^ *", line).match) < indentation && break + push!(block, isempty(strip(line)) ? "" : line[(indentation + 1):end]) + end + return join(block, '\n') * '\n' +end diff --git a/ReleaseFlow/test/runtests.jl b/ReleaseFlow/test/runtests.jl index d0463bf..6ffcade 100644 --- a/ReleaseFlow/test/runtests.jl +++ b/ReleaseFlow/test/runtests.jl @@ -15,3 +15,4 @@ include("trunk.jl") include("initial.jl") include("subdirectory.jl") include("back_merge.jl") +include("tag.jl") diff --git a/ReleaseFlow/test/tag.jl b/ReleaseFlow/test/tag.jl new file mode 100644 index 0000000..040f0a6 --- /dev/null +++ b/ReleaseFlow/test/tag.jl @@ -0,0 +1,58 @@ +# Moving the major tags +# --------------------- + +# The tag workflow's step, run in a repository with version tags, with a stand-in for `gh` that logs its calls. +@testset "Tag" begin + directory = mktempdir() + bin = mkpath(joinpath(directory, "bin")) + write(joinpath(bin, "gh"), "#!/bin/bash\necho \"gh \$*\" >> \"\$LOG\"\n") + chmod(joinpath(bin, "gh"), 0o755) + log = joinpath(directory, "log") + script = joinpath(directory, "step.sh") + write(script, workflow_step("tag.yml", "Move the major tags")) + work = repository() + git(work, "config", "tag.gpgsign", "false") + commits = [commit!(work, "Commit $(i)", "file.txt" => string(i)) for i in 1:6] + function step() + write(log, "") + environment = Dict("PATH" => "$(bin):$(ENV["PATH"])", "LOG" => log, "REPOSITORY" => "Example/Toy.jl") + 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)), log = read(log, String)) + end + tag!(name, commit) = git(work, "tag", "-f", name, commit) + created(major, commit) = "gh api repos/Example/Toy.jl/git/refs -f ref=refs/tags/$(major) -f sha=$(commit)" + moved(major, commit) = "gh api --method PATCH repos/Example/Toy.jl/git/refs/tags/$(major) -f sha=$(commit) -F force=true" + + # Without version tags, there's nothing to move. + result = step() + @test result.code == 0 && occursin("There's no version tag vX.Y.Z", result.output) && isempty(result.log) + # The newest release of a major version without its major tag: the major tag is made at its commit. + tag!("v1.0.0", commits[1]) + tag!("v1.0.1", commits[2]) + result = step() + @test result.code == 0 && result.log == created("v1", commits[2]) * "\n" + # A major tag at an older release moves to the newest. + tag!("v1", commits[1]) + result = step() + @test result.log == moved("v1", commits[2]) * "\n" + # A major tag at the newest release stays. + tag!("v1", commits[2]) + result = step() + @test isempty(result.log) && occursin("v1 is at v1.0.1, $(commits[2]), already.", result.output) + # Each major version's tag follows its own newest release, by version rather than by date, and pre-releases are left out. + tag!("v1.10.0", commits[3]) + tag!("v1.9.0", commits[4]) + tag!("v2.0.0", commits[5]) + tag!("v2.1.0-rc1", commits[6]) + result = step() + @test result.log == moved("v1", commits[3]) * "\n" * created("v2", commits[5]) * "\n" + # A version tag pushed by hand, older than the newest, moves nothing. + tag!("v1", commits[3]) + tag!("v2", commits[5]) + tag!("v1.0.5", commits[6]) + @test isempty(step().log) + # An annotated tag counts at its commit. + git(work, "tag", "-a", "v2.2.0", "-m", "Release 2.2.0", commits[6]) + @test step().log == moved("v2", commits[6]) * "\n" +end