diff --git a/.github/workflows/TagBot.yml b/.github/workflows/TagBot.yml index 9910a73..49ae5dd 100644 --- a/.github/workflows/TagBot.yml +++ b/.github/workflows/TagBot.yml @@ -34,5 +34,8 @@ jobs: # 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 + # The release branch, whose commits TagBot is to tag: the back-merge into develop shares a release's tree, and without the branch TagBot may pick it. + # TagBot reads this first once it searches the release branch's history first, which its README documents, and only sets the release's target until then. + branch: master # 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 index 65c36cb..26c2d0c 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -79,13 +79,30 @@ jobs: REPOSITORY: ${{ github.repository }} run: | set -euo pipefail - # Each outcome is a notice on the run, and a line of the job's summary. + # Each outcome is a notice on the run, and a line of the job's summary; a failure is an error, and fails the step once every major tag is handled. summary="${GITHUB_STEP_SUMMARY:-/dev/null}" echo "## Major tags" >> "$summary" report() { echo "::notice title=Tag::$1" echo "- $1" >> "$summary" } + failed=false + fail() { + echo "::error title=Tag::$1" + echo "- $1" >> "$summary" + failed=true + } + # Run `gh`, and report its own message where it fails. + call() { + local what="$1" + shift + local message + if message=$(gh "$@" 2>&1 > /dev/null); then + return 0 + fi + fail "Couldn't $what: ${message//$'\n'/ }" + return 1 + } # 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 @@ -100,11 +117,15 @@ jobs: current=$(git rev-parse --verify --quiet "refs/tags/$major^{commit}" || true) if [ "$current" = "$target" ]; then report "$major is at $newest, $short, already." + elif [ -n "$current" ] && ! git merge-base --is-ancestor "$current" "$target"; then + # A major tag only moves forwards: a newest release that isn't descended from it, such as once a newer version's tag is deleted, leaves it where it is. + report "$major is at $(git rev-parse --short "$current"), which $newest, $short, doesn't descend from, so it stays." elif [ -n "$current" ]; then - gh api --method PATCH "repos/$REPOSITORY/git/refs/tags/$major" -f sha="$target" -F force=true > /dev/null - report "Moved $major to $newest, $short." + call "move $major to $newest, $short" api --method PATCH "repos/$REPOSITORY/git/refs/tags/$major" -f sha="$target" -F force=true && report "Moved $major to $newest, $short." else - gh api "repos/$REPOSITORY/git/refs" -f ref="refs/tags/$major" -f sha="$target" > /dev/null - report "Made $major at $newest, $short." + call "make $major at $newest, $short" api "repos/$REPOSITORY/git/refs" -f ref="refs/tags/$major" -f sha="$target" && report "Made $major at $newest, $short." fi done + if [ "$failed" = true ]; then + exit 1 + fi diff --git a/README.md b/README.md index 2a87f79..bd38fd1 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,7 @@ jobs: 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. +It only moves a major tag forwards, to a release that descends from where the tag is, so deleting a version's tag doesn't take the major tag back to an older release; and where GitHub refuses a move, it reports GitHub's message as an error, and fails once every major tag is handled. 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. For each major tag, it reports what it did, made it, moved it or left it, as a notice on the run and a line of the job's summary, under the job ` / Major tags`. diff --git a/ReleaseFlow/CHANGELOG.md b/ReleaseFlow/CHANGELOG.md index b352ae9..d8add4a 100644 --- a/ReleaseFlow/CHANGELOG.md +++ b/ReleaseFlow/CHANGELOG.md @@ -8,8 +8,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### 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, leaves pre-releases out, and reports what it did for each major tag as a notice on the run and a line of the job's summary. +- 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 only moves a major tag forwards, to a release that descends from where it is, leaves pre-releases out, and reports what it did for each major tag as a notice on the run and a line of the job's summary, or, where GitHub refuses a move, an error with GitHub's message. - 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. +- This repository's TagBot names `master` as its release branch, so that its tags land on `master`'s merge commits rather than on the back-merges into `develop`, which share their trees, once TagBot searches that branch first. - 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. diff --git a/ReleaseFlow/test/tag.jl b/ReleaseFlow/test/tag.jl index 2ddb2a4..dc9f873 100644 --- a/ReleaseFlow/test/tag.jl +++ b/ReleaseFlow/test/tag.jl @@ -1,11 +1,11 @@ # 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. +# The tag workflow's step, run in a repository with version tags, with a stand-in for `gh` that logs its calls, and refuses them with `GH_REFUSE`'s message where that's set. @testset "Tag" begin directory = mktempdir() bin = mkpath(joinpath(directory, "bin")) - write(joinpath(bin, "gh"), "#!/bin/bash\necho \"gh \$*\" >> \"\$LOG\"\n") + write(joinpath(bin, "gh"), "#!/bin/bash\necho \"gh \$*\" >> \"\$LOG\"\nif [ -n \"\$GH_REFUSE\" ]; then echo \"\$GH_REFUSE\" >&2; exit 1; fi\n") chmod(joinpath(bin, "gh"), 0o755) log = joinpath(directory, "log") script = joinpath(directory, "step.sh") @@ -14,10 +14,10 @@ git(work, "config", "tag.gpgsign", "false") commits = [commit!(work, "Commit $(i)", "file.txt" => string(i)) for i in 1:6] summary = joinpath(directory, "summary.md") - function step() + function step(; refuse = "") write(log, "") write(summary, "") - environment = Dict("PATH" => "$(bin):$(ENV["PATH"])", "LOG" => log, "REPOSITORY" => "Example/Toy.jl", "GITHUB_STEP_SUMMARY" => summary) + environment = Dict("PATH" => "$(bin):$(ENV["PATH"])", "LOG" => log, "REPOSITORY" => "Example/Toy.jl", "GITHUB_STEP_SUMMARY" => summary, "GH_REFUSE" => refuse) 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), summary = read(summary, String)) @@ -65,4 +65,19 @@ # 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" + # A major tag only moves forwards: once the newest version's tag is deleted, the newest left is behind the major tag, which stays. + tag!("v2", commits[6]) + git(work, "tag", "-d", "v2.2.0") + result = step() + @test result.code == 0 && !occursin("v2", result.log) + @test occursin("::notice title=Tag::v2 is at $(short(commits[6])), which v2.0.0, $(short(commits[5])), doesn't descend from, so it stays.", result.output) + # A refused move is an error with GitHub's message, the other major tags are still handled, and the step fails. + tag!("v1", commits[1]) + tag!("v2", commits[1]) + result = step(refuse = "HTTP 403: Resource not accessible by integration\nsecond line") + @test result.code == 1 + @test occursin(moved("v1", commits[3]), result.log) && occursin(moved("v2", commits[5]), result.log) + @test occursin("::error title=Tag::Couldn't move v1 to v1.10.0, $(short(commits[3])): HTTP 403: Resource not accessible by integration second line", result.output) + @test occursin("- Couldn't move v2 to v2.0.0, $(short(commits[5])): HTTP 403", result.summary) + @test !occursin("Moved", result.summary) end