Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/Self-Tag.yml
Original file line number Diff line number Diff line change
@@ -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 }}
22 changes: 19 additions & 3 deletions .github/workflows/TagBot.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
101 changes: 101 additions & 0 deletions .github/workflows/tag.yml
Original file line number Diff line number Diff line change
@@ -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
44 changes: 39 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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 `<Package>-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
Expand Down Expand Up @@ -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.
Expand All @@ -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".
Expand Down Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions ReleaseFlow/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions ReleaseFlow/test/helpers.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions ReleaseFlow/test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ include("trunk.jl")
include("initial.jl")
include("subdirectory.jl")
include("back_merge.jl")
include("tag.jl")
58 changes: 58 additions & 0 deletions ReleaseFlow/test/tag.jl
Original file line number Diff line number Diff line change
@@ -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
Loading