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
28 changes: 23 additions & 5 deletions .github/workflows/release-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ on:
tag-prefix:
type: string
default: v
image-tag-prefix:
description: >-
Prefix of the image tag when it differs from the git tag's (a
repository with several release projects: git tag db-v1.2.3, image
tag v1.2.3). Empty = tag-prefix.
type: string
default: ""
dockerfile:
type: string
default: Dockerfile
Expand All @@ -62,7 +69,7 @@ on:
build-args:
description: >-
Newline-separated KEY=VALUE build arguments; {tag} in a value becomes
the release tag (VERSION={tag} -> VERSION=v1.2.3; a dry run gets the
the image tag (VERSION={tag} -> VERSION=v1.2.3; a dry run gets the
tag it would release).
type: string
default: ""
Expand Down Expand Up @@ -111,6 +118,9 @@ on:
value: ${{ jobs.publish.outputs.digest }}
tag:
value: ${{ jobs.verify.outputs.tag }}
image-tag:
description: The image's tag (image-tag-prefix + version).
value: ${{ jobs.verify.outputs.image-tag }}

# No workflow-level permissions: every job names its own (contents: read,
# id-token: write), so a release pull request's dry run starts with read rights.
Expand All @@ -128,6 +138,7 @@ jobs:
id-token: write
outputs:
tag: ${{ steps.version.outputs.tag }}
image-tag: ${{ steps.image.outputs.tag }}
mode: ${{ steps.version.outputs.mode }}
push: ${{ steps.version.outputs.push }}
build: ${{ steps.registry.outputs.build || steps.version.outputs.build }}
Expand Down Expand Up @@ -201,14 +212,21 @@ jobs:
service_account: ${{ inputs.service-account }}
token_format: access_token
access_token_lifetime: 300s
- name: the image tag
id: image
env:
TAG: ${{ steps.version.outputs.tag }}
TAG_PREFIX: ${{ inputs.tag-prefix }}
IMAGE_TAG_PREFIX: ${{ inputs.image-tag-prefix }}
run: echo "tag=${IMAGE_TAG_PREFIX:-$TAG_PREFIX}${TAG#"$TAG_PREFIX"}" >> "$GITHUB_OUTPUT"
- name: the tag is not in the registry yet
id: registry
if: steps.version.outputs.push == 'true'
env:
TOKEN: ${{ steps.auth.outputs.access_token }}
REGISTRY: ${{ steps.version.outputs.registry }}
IMAGE: ${{ inputs.image }}
TAG: ${{ steps.version.outputs.tag }}
TAG: ${{ steps.image.outputs.tag }}
MODE: ${{ steps.version.outputs.mode }}
SHA: ${{ github.sha }}
run: |
Expand Down Expand Up @@ -275,7 +293,7 @@ jobs:
BUILD_ARGS: ${{ inputs.build-args }}
SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}
REVISION: ${{ github.sha }}
VERSION: ${{ needs.verify.outputs.tag }}
VERSION: ${{ needs.verify.outputs.image-tag }}
ARTIFACT_PREFIX: ${{ inputs.artifact-prefix }}
run: |
args=(--platform "$PLATFORM" -f "$DOCKERFILE"
Expand Down Expand Up @@ -339,13 +357,13 @@ jobs:
service_account: ${{ inputs.service-account }}
token_format: access_token
access_token_lifetime: 600s
- name: multi-arch index ${{ needs.verify.outputs.tag }}
- name: multi-arch index ${{ needs.verify.outputs.image-tag }}
id: index
env:
TOKEN: ${{ steps.auth.outputs.access_token }}
REGISTRY: ${{ needs.verify.outputs.registry }}
IMAGE: ${{ inputs.image }}
TAG: ${{ needs.verify.outputs.tag }}
TAG: ${{ needs.verify.outputs.image-tag }}
LATEST: ${{ inputs.latest }}
PLATFORMS: ${{ inputs.platforms }}
run: |
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/release-intent.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ on:
tag-prefix:
type: string
default: v
release-name:
description: >-
This project's name, in a repository with several release projects
(each with its own version file and tag prefix): a release pull
request of another project is then a no-release for this one.
Empty = the repository's only project.
type: string
default: ""
harness-ref:
description: Ref of IronFlock/workflows the harness scripts are taken from.
type: string
Expand Down Expand Up @@ -72,6 +80,7 @@ jobs:
env:
VERSION_FILE: ${{ inputs.version-file }}
TAG_PREFIX: ${{ inputs.tag-prefix }}
RELEASE_NAME: ${{ inputs.release-name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
TITLE: ${{ github.event.pull_request.title }}
Expand Down
35 changes: 34 additions & 1 deletion .github/workflows/selftest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,39 @@ jobs:
artifact-prefix: package-json-
harness-ref: ${{ github.sha }}

# A second release project in a repository: git tag db-v<version>, image tag
# v<version>.
release-dry-run-image-tag:
uses: ./.github/workflows/release-image.yml
permissions:
contents: read
id-token: write
with:
image: example.invalid/selftest/release
version-file: tests/fixtures/release/version.txt
tag-prefix: db-v
image-tag-prefix: v
dockerfile: tests/fixtures/release/Dockerfile
context: tests/fixtures/release
build-args: VERSION={tag}
workload-identity-provider: dry-run-unused
service-account: dry-run-unused
artifact-prefix: image-tag-
harness-ref: ${{ github.sha }}

assert-image-tag:
needs: [release-dry-run-image-tag]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: git tag db-v0.0.1, image tag v0.0.1
env:
TAG: ${{ needs.release-dry-run-image-tag.outputs.tag }}
IMAGE_TAG: ${{ needs.release-dry-run-image-tag.outputs.image-tag }}
run: |
echo "tag $TAG, image tag $IMAGE_TAG"
[ "$TAG" = db-v0.0.1 ] && [ "$IMAGE_TAG" = v0.0.1 ]

# A named build context from an artifact of the same run.
build-context-artifact:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -342,7 +375,7 @@ jobs:
# above succeeded (skipped or cancelled counts as failed).
selftest-ok:
if: ${{ always() }}
needs: [lint-self, lint-templates, lint-planted, scripts, gate-pass, gate-fail, node-gate-npm, node-gate-npm-tools, node-gate-bun, node-gate-fail, python-gate-pass, python-gate-fail, security-clean, security-clean-node, security-planted, release-dry-run, release-dry-run-package-json, build-context-artifact, release-dry-run-build-context, assert-planted]
needs: [lint-self, lint-templates, lint-planted, scripts, gate-pass, gate-fail, node-gate-npm, node-gate-npm-tools, node-gate-bun, node-gate-fail, python-gate-pass, python-gate-fail, security-clean, security-clean-node, security-planted, release-dry-run, release-dry-run-package-json, build-context-artifact, release-dry-run-build-context, release-dry-run-image-tag, assert-image-tag, assert-planted]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ the files a bump changes, and writes the version in `_write-version`; copy
`release/v<x>`. It then waits for the merge and for the release run, and
ends back on `main` (detached on `origin/main` in a worktree).

A repository with several release projects (each its own version file, tag
prefix and release workflow, e.g. `db-v*` and `migrator-v*`): each project's
`release-intent` call gets its `release-name`, so a release pull request of
one project is a no-release for the others (and may not change their
versions); `pr.sh release` takes `TAG_PREFIX` and `WRITE_VERSION` (the
project's version recipe) from the environment, and `pr.sh merge` a
comma-separated list of every version file. One release per pull request.

Onboarding a repository onto this flow, besides the caller files:

- `main` ruleset: squash merges only; repository setting "default commit
Expand Down
35 changes: 20 additions & 15 deletions scripts/pr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
# script at IronFlock/workflows@v1 and runs it, so the flow is the same
# everywhere (see "Pull requests from a laptop" in the README).
#
# pr.sh merge <version-file|-> [<title>]
# pr.sh merge <version-file[,version-file...]|-> [<title>]
# a no-release pull request from the current feature branch (pushed even
# if origin does not have it yet), titled <title> (default: the subject
# of the branch's first commit), merged automatically (squash) once the
# required checks pass. The version must equal main's (`-`: a repository
# without a version, whose main is the publication), and the title must
# not start with "release ": such a merge releases nothing.
# required checks pass. Every version must equal main's (several
# comma-separated files: a repository with several release projects; `-`:
# a repository without a version, whose main is the publication), and the
# title must not start with "release ": such a merge releases nothing.
#
# pr.sh release <patch|minor|major> <name> <version-file> <workflow> <file>...
# a release pull request. The next version is computed from the version
Expand All @@ -23,7 +24,9 @@
# required checks pass; its merge starts <workflow> (the image and the
# tag), and this waits for that run.
#
# Env: PR_WAIT_SECONDS (default 20) between polls.
# Env: PR_WAIT_SECONDS (default 20) between polls. For one of several release
# projects in a repository: TAG_PREFIX (default v; e.g. db-v) and
# WRITE_VERSION (default _write-version; the project's recipe).
set -euo pipefail

wait_s=${PR_WAIT_SECONDS:-20}
Expand Down Expand Up @@ -115,8 +118,8 @@ leave_branch() {
}

cmd_merge() {
[ $# -ge 1 ] || die "usage: pr.sh merge <version-file|-> [<title>]"
local vf=$1 title=${2:-} branch here there
[ $# -ge 1 ] || die "usage: pr.sh merge <version-file[,version-file...]|-> [<title>]"
local vfs=$1 title=${2:-} branch here there vf
branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$branch" = main ] || [ "$branch" = HEAD ]; then
die "merge: run on a feature branch"
Expand All @@ -126,11 +129,13 @@ cmd_merge() {
git fetch --quiet origin main
git merge-base --is-ancestor origin/main HEAD ||
die "merge: $branch does not contain origin/main (git rebase origin/main)"
if [ "$vf" != - ]; then
here=$(version "$vf")
there=$(version "$vf" origin/main)
[ "$here" = "$there" ] ||
die "merge: $branch changes the version ($there -> $here): that is a release (just release-patch)"
if [ "$vfs" != - ]; then
for vf in ${vfs//,/ }; do
here=$(version "$vf")
there=$(version "$vf" origin/main)
[ "$here" = "$there" ] ||
die "merge: $branch changes $vf ($there -> $here): that is a release (just release-patch)"
done
fi
[ -n "$title" ] || title=$(git log --reverse --format=%s origin/main..HEAD | head -n1)
[ -n "$title" ] || die "merge: no commits on $branch beyond main"
Expand Down Expand Up @@ -165,11 +170,11 @@ cmd_release() {
base=$(version "$vf" origin/main)
[ -n "$base" ] || die "release: $vf has no version on origin/main"
next=$(next_version "$level" "$base")
tag="v$next"
tag="${TAG_PREFIX:-v}$next"
title="release $name $tag"
if [ "$(version "$vf")" != "$next" ]; then
just _write-version "$next"
[ "$(version "$vf")" = "$next" ] || die "release: just _write-version $next left $vf at $(version "$vf")"
just "${WRITE_VERSION:-_write-version}" "$next"
[ "$(version "$vf")" = "$next" ] || die "release: just ${WRITE_VERSION:-_write-version} $next left $vf at $(version "$vf")"
fi
echo "==> $title (main has $base)"

Expand Down
32 changes: 28 additions & 4 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
#
# release.sh intent
# the release intent of a pull request into main. Env: VERSION_FILE,
# TAG_PREFIX, BASE_SHA, HEAD_SHA, TITLE. The version file decides:
# TAG_PREFIX, BASE_SHA, HEAD_SHA, TITLE, RELEASE_NAME (optional: one of
# several release projects in the repository). The version file decides:
# release the version rises; the title is `release <name> <tag>`
# (just release-patch|minor|major)
# no-release the version is unchanged (just merge); any title except
Expand Down Expand Up @@ -72,6 +73,25 @@ intent() {
head=$(version "$VERSION_FILE" "$HEAD_SHA")
[ -n "$head" ] || { echo "::error::$VERSION_FILE has no version at $HEAD_SHA"; return 1; }

# Several release projects in one repository (RELEASE_NAME set per project):
# a release title that names another project is a no-release for this one,
# whose version must then stay as it is (one release per pull request).
local lower named=""
# shellcheck disable=SC2153 # TITLE comes from the environment (see the header)
lower=$(printf '%s' "$TITLE" | tr '[:upper:]' '[:lower:]')
if [ -n "${RELEASE_NAME:-}" ] && [[ "$lower" =~ ^release\ ([^\ ]+)\ ]]; then
named=${BASH_REMATCH[1]}
fi
if [ -n "$named" ] && [ "$named" != "$RELEASE_NAME" ]; then
if [ "$head" != "$base" ]; then
echo "::error::$VERSION_FILE changes ($base -> $head), but the pull request releases $named: one release per pull request"
return 1
fi
echo "a release of $named: $RELEASE_NAME releases nothing"
out kind=no-release
return 0
fi

if [ "$head" = "$base" ]; then
# The squash merge carries this title, and release.yml starts on a merge
# whose message starts with 'release ' (GitHub compares case-insensitively).
Expand Down Expand Up @@ -120,12 +140,16 @@ mode() {
# The squash merge of a release pull request: `release <name> <tag> (#n)`.
title=$(head -n1 <<<"$COMMIT_TITLE")
title=${title% (#*)}
if ! [[ "$title" =~ ^release\ [^\ ]+\ (.+)$ ]]; then
if ! [[ "$title" =~ ^release\ ([^\ ]+)\ (.+)$ ]]; then
echo "::error::'$title' is not the merge of a release pull request: nothing to release"
return 1
fi
if [ "${BASH_REMATCH[1]}" != "$tag" ]; then
echo "::error::the merged release pull request names ${BASH_REMATCH[1]}, but $VERSION_FILE holds $tag"
if [ -n "${RELEASE_NAME:-}" ] && [ "${BASH_REMATCH[1]}" != "$RELEASE_NAME" ]; then
echo "::error::'$title' releases ${BASH_REMATCH[1]}, not $RELEASE_NAME"
return 1
fi
if [ "${BASH_REMATCH[2]}" != "$tag" ]; then
echo "::error::the merged release pull request names ${BASH_REMATCH[2]}, but $VERSION_FILE holds $tag"
return 1
fi
if git rev-parse --quiet --verify "refs/tags/$tag" >/dev/null; then
Expand Down
28 changes: 27 additions & 1 deletion scripts/test_pr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ EOF
cat >"$work/bin/just" <<'EOF'
#!/usr/bin/env bash
echo "just $*" >>"$STUB/log"
[ "$1" = _write-version ] || exit 1
case "$1" in _write-*) ;; *) exit 1 ;; esac
vf=$(cat "$STUB/vf")
if [ "$(basename "$vf")" = package.json ]; then
jq --arg v "$2" '.version = $v' "$vf" >"$vf.tmp" && mv "$vf.tmp" "$vf"
Expand Down Expand Up @@ -239,5 +239,31 @@ check "release: pyproject.toml" 0 release patch x pyproject.toml release.yml pyp
expect "release: [project] version at 1.10.1" grep -qx 'version = "1.10.1"' <(git -C "$origin" show feat:pyproject.toml)
expect "release: other tables untouched" grep -qx 'version = "9.9.9"' <(git -C "$origin" show feat:pyproject.toml)

# Several release projects in one repository.
setup VERSION 1.2.3
mkdir -p "$repo/migrator" && echo 0.3.5 >"$repo/migrator/VERSION"
git -C "$repo" add -A && git -C "$repo" commit -q -m "a second project"
git -C "$repo" push -q origin feat
git -C "$repo" switch -q main && git -C "$repo" merge -q --ff-only feat && git -C "$repo" push -q origin main && git -C "$repo" switch -q feat
echo "change" >>"$repo/work.txt" && git -C "$repo" commit -q -am "more work"
check "merge: several version files, none changed" 0 merge VERSION,migrator/VERSION
echo 0.3.6 >"$repo/migrator/VERSION" && git -C "$repo" commit -q -am "bump the second project by hand"
check "merge: refused when the second version file changes" 1 merge VERSION,migrator/VERSION

setup VERSION 1.2.3
ci_tag db-v1.2.4
export TAG_PREFIX=db-v
check "release: TAG_PREFIX names the tag" 0 release patch platform-db VERSION release-db.yml VERSION
unset TAG_PREFIX
expect "release: title with the prefixed tag" logged "gh pr create --base main --head feat --title release platform-db db-v1.2.4"
expect "release: the prefixed tag fetched" git -C "$repo" rev-parse --verify refs/tags/db-v1.2.4

setup VERSION 1.2.3
ci_tag v1.2.4
export WRITE_VERSION=_write-db-version
check "release: WRITE_VERSION names the project's recipe" 0 release patch x VERSION release.yml VERSION
unset WRITE_VERSION
expect "release: the project's recipe was called" logged "just _write-db-version 1.2.4"

[ "$fail" = 0 ] && echo "all pr.sh tests passed"
exit "$fail"
26 changes: 26 additions & 0 deletions scripts/test_release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,27 @@ for vf in VERSION package.json pyproject.toml; do
check "$vf: 1.2.3 -> 1.10.0 is higher (version sort)" 0 intent "$r" "$vf" "$base" "$minor" "release x v1.10.0"
done

# Several release projects in one repository: RELEASE_NAME per project.
intent_named() { # intent_named <dir> <file> <base> <head> <title> <name>
(cd "$1" && VERSION_FILE=$2 TAG_PREFIX=db-v BASE_SHA=$3 HEAD_SHA=$4 TITLE=$5 RELEASE_NAME=$6 "$script" intent)
}
r="$work/intent-named"
repo "$r" VERSION 1.2.3
base=$(git -C "$r" rev-parse HEAD)
same=$(commit "$r" "docs")
export GITHUB_OUTPUT="$work/out-named"
: > "$GITHUB_OUTPUT"
check "named: another project's release is a no-release here" 0 intent_named "$r" VERSION "$base" "$same" "release platform-db-migrator migrator-v0.3.36" platform-db
expect_output "named: another project's release: kind no-release" "kind=no-release"
unset GITHUB_OUTPUT
check "named: this project's title without a bump" 1 intent_named "$r" VERSION "$base" "$same" "release platform-db db-v1.2.3" platform-db
git -C "$r" checkout -q "$base"
setv "$r" VERSION 1.2.4
bump=$(commit "$r" "bump")
check "named: this project's release PR" 0 intent_named "$r" VERSION "$base" "$bump" "release platform-db db-v1.2.4" platform-db
check "named: a bump under another project's release title" 1 intent_named "$r" VERSION "$base" "$bump" "release platform-db-migrator migrator-v0.3.36" platform-db
check "named: the prefix of this project's tag is required" 1 intent_named "$r" VERSION "$base" "$bump" "release platform-db v1.2.4" platform-db

mode() { # mode <dir> <event> <ref> <ref type> <ref name> <commit title>
(cd "$1" && VERSION_FILE=VERSION TAG_PREFIX=v EVENT=$2 REF=$3 REF_TYPE=$4 REF_NAME=$5 \
RELEASE_BRANCH=main COMMIT_TITLE=$6 SHA=$(git rev-parse HEAD) "$script" mode)
Expand All @@ -113,6 +134,11 @@ expect_output "merge of a release PR: tag" "tag=v2.0.0"
check "merge title with the PR body below" 0 mode "$r" push refs/heads/main branch main $'release x v2.0.0 (#12)\n\nbody'
check "merge of a no-release PR is refused" 1 mode "$r" push refs/heads/main branch main "docs: typo (#13)"
check "merge naming another version is refused" 1 mode "$r" push refs/heads/main branch main "release x v1.9.0 (#14)"
check "merge of a release PR: any name without RELEASE_NAME" 0 mode "$r" push refs/heads/main branch main "release y v2.0.0 (#12)"
named_mode() { (cd "$r" && VERSION_FILE=VERSION TAG_PREFIX=v EVENT=push REF=refs/heads/main REF_TYPE=branch REF_NAME=main \
RELEASE_BRANCH=main COMMIT_TITLE=$1 SHA=$(git rev-parse HEAD) RELEASE_NAME=x "$script" mode); }
check "merge of another project's release PR is refused with RELEASE_NAME" 1 named_mode "release y v2.0.0 (#12)"
check "merge of this project's release PR with RELEASE_NAME" 0 named_mode "release x v2.0.0 (#12)"
git -C "$r" tag -a v2.0.0 -m "release x v2.0.0"
: > "$GITHUB_OUTPUT"
check "a re-run after a complete release passes" 0 mode "$r" push refs/heads/main branch main "release x v2.0.0 (#12)"
Expand Down
Loading