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
7 changes: 5 additions & 2 deletions .github/workflows/release-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ on:
type: string
default: "."
build-args:
description: Newline-separated KEY=VALUE build arguments.
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
tag it would release).
type: string
default: ""
platforms:
Expand Down Expand Up @@ -268,7 +271,7 @@ jobs:
--label "org.opencontainers.image.version=$VERSION"
--metadata-file "$RUNNER_TEMP/metadata.json")
while IFS= read -r arg; do
[ -n "$arg" ] && args+=(--build-arg "$arg")
[ -n "$arg" ] && args+=(--build-arg "${arg//\{tag\}/$VERSION}")
done <<<"$BUILD_ARGS"
if [ "$PUSH" = true ]; then
echo "$TOKEN" | docker login -u oauth2accesstoken --password-stdin "https://$REGISTRY"
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/selftest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ jobs:
run: python3 -I scripts/test_sarif_summary.py
- name: release.sh unit tests
run: bash scripts/test_release.sh
- name: pr.sh unit tests
run: bash scripts/test_pr.sh
# Public repository: no internal document names or plan IDs.
- name: public-repo hygiene
run: |
Expand Down Expand Up @@ -258,6 +260,7 @@ jobs:
version-file: tests/fixtures/release/version.txt
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: version-file-
Expand All @@ -274,6 +277,7 @@ jobs:
version-file: tests/fixtures/node-pass-npm/package.json
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: package-json-
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,35 @@ deployments from `main` only, variable `RELEASE_APP_CLIENT_ID`, secret
repository. Callers grant `contents: read` only and pass `secrets: inherit`:
environment secrets reach a reusable workflow only that way.

## Pull requests from a laptop

`just merge` and `just release-patch|minor|major` behave the same in every
repository: the Justfile fetches [scripts/pr.sh](scripts/pr.sh) at `v1` (with
`gh`) and runs it. A repository only names its release, its version file and
the files a bump changes, and writes the version in `_write-version`; copy
[templates/repo/Justfile.release](templates/repo/Justfile.release).

- `just merge [TITLE]`: pushes the current feature branch (also when origin
does not have it yet), opens the pull request and turns on auto-merge. It
refuses a changed version and a title starting with `release `.
- `just release-patch`: the next version is computed from the version on
`origin/main`, so a second run on the same branch does not bump twice. The
release commit `release <name> v<x>` holds only the listed files and sits on
top of the branch's work; on an up-to-date `main` it goes on a new branch
`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).

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

- `main` ruleset: squash merges only; repository setting "default commit
message" = pull request title (so the merge commit carries the checked
title).
- Environment `release`: deployment branches `main` only; variable
`RELEASE_APP_CLIENT_ID`, secret `RELEASE_APP_PRIVATE_KEY`.
- The release GitHub App installed on the repository and listed as bypass
actor of the `release tags v*` ruleset.
- The publisher binding of the identity provider for the repository.

## Exceptions

Each exception needs a reason and an expiry, and is reviewed in the pull
Expand Down
201 changes: 201 additions & 0 deletions scripts/pr.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
# Pull requests into main from a laptop: the logic behind `just merge` and
# `just release-patch|minor|major` in every repository. Run from the repository
# root; needs git, gh (logged in) and jq. The repository's Justfile fetches this
# 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>]
# 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, 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
# on origin/main (so a second run on the same branch does not bump twice)
# and written by `just _write-version <version>`. Only the listed files
# are committed, as `release <name> v<version>`, on top of the work of the
# current feature branch, or on a new branch release/v<version> when run
# on an up-to-date main. The pull request merges automatically once the
# 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.
set -euo pipefail

wait_s=${PR_WAIT_SECONDS:-20}

die() { echo "$*" >&2; exit 1; }

# version <file> [<rev>]: a package.json's `version`, otherwise the content
# without whitespace (same rule as release.sh).
version() {
local content
if [ -n "${2:-}" ]; then
content=$(git show "$2:$1" 2>/dev/null) || return 0
else
content=$(cat "$1")
fi
if [ "$(basename "$1")" = package.json ]; then
jq -r '.version // empty' <<<"$content"
else
tr -d '[:space:]' <<<"$content"
fi
}

next_version() { # next_version <level> <x.y.z>
local major minor patch
IFS=. read -r major minor patch <<<"$2"
case "$1" in
patch) echo "$major.$minor.$((patch + 1))" ;;
minor) echo "$major.$((minor + 1)).0" ;;
major) echo "$((major + 1)).0.0" ;;
*) die "level must be patch, minor or major, got '$1'" ;;
esac
}

require_clean() {
local dirty
dirty=$(git status --porcelain)
[ -z "$dirty" ] || die "uncommitted changes: commit them first, with a message describing the work
$dirty"
}

# The title is checked like release-intent.yml does: a title starting with
# "release " (any case) belongs to a release pull request.
release_title() {
[[ "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" == "release "* ]]
}

open_pr() { # open_pr <branch>: number of the branch's open pull request, if any
gh pr list --head "$1" --state open --json number --jq '.[0].number // empty'
}

await_merge() { # await_merge <branch>: waits up to 40 minutes
local state=""
echo "==> waiting for the required checks and the merge of $1"
for _ in $(seq 1 120); do
state=$(gh pr view "$1" --json state --jq .state)
case "$state" in
MERGED) return 0 ;;
CLOSED) die "the pull request of $1 was closed without merging" ;;
esac
sleep "$wait_s"
done
die "the pull request of $1 is not merged after 40 minutes: gh pr checks $1"
}

# Back on main after the merge; in a worktree main is checked out elsewhere,
# so this continues detached on origin/main.
leave_branch() {
git fetch --quiet origin main
if git switch --quiet main 2>/dev/null; then
git pull --quiet --ff-only
else
git switch --quiet --detach origin/main
fi
git branch -D "$1" >/dev/null 2>&1 || true
}

cmd_merge() {
[ $# -ge 1 ] || die "usage: pr.sh merge <version-file> [<title>]"
local vf=$1 title=${2:-} branch here there
branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$branch" = main ] || [ "$branch" = HEAD ]; then
die "merge: run on a feature branch"
fi
require_clean
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)"
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)"
[ -n "$title" ] || title=$(git log --reverse --format=%s origin/main..HEAD | head -n1)
[ -n "$title" ] || die "merge: no commits on $branch beyond main"
if release_title "$title"; then
die "merge: '$title' starts with 'release ' (the title of release pull requests); pass another title"
fi
git push --quiet -u origin "$branch"
if [ -z "$(open_pr "$branch")" ]; then
gh pr create --base main --head "$branch" --title "$title" \
--body "Opened by \`just merge\`. Merged automatically once the required checks pass; the merge releases nothing."
fi
gh pr merge "$branch" --squash --auto --delete-branch
echo "==> $title: merges automatically once the required checks pass"
}

cmd_release() {
[ $# -ge 5 ] || die "usage: pr.sh release <patch|minor|major> <name> <version-file> <workflow> <file>..."
local level=$1 name=$2 vf=$3 workflow=$4
shift 4
local base next tag title branch pr sha run
require_clean
git fetch --quiet origin main
branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$branch" = main ]; then
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] ||
die "release: main is not up to date (git pull --ff-only)"
elif [ "$branch" = HEAD ] || ! git merge-base --is-ancestor origin/main HEAD; then
die "release: run on main or on a branch that contains origin/main (git rebase origin/main)"
fi

base=$(version "$vf" origin/main)
[ -n "$base" ] || die "release: $vf has no version on origin/main"
next=$(next_version "$level" "$base")
tag="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")"
fi
echo "==> $title (main has $base)"

if [ "$branch" = main ]; then
branch="release/$tag"
git switch --quiet -c "$branch"
fi
pr=$(open_pr "$branch")
# The title before the push: the checks that the push starts read it.
if [ -n "$pr" ]; then
gh pr edit "$pr" --title "$title" >/dev/null
fi
git add -- "$@"
if ! git diff --cached --quiet; then
git commit --quiet -m "$title"
fi
[ -z "$(git status --porcelain)" ] ||
die "release: _write-version changed files outside the list ($*): $(git status --porcelain | tr '\n' ' ')"
git push --quiet -u origin "$branch"
if [ -z "$pr" ]; then
gh pr create --base main --head "$branch" --title "$title" \
--body "Opened by \`just release-$level\`. Merged automatically once the required checks pass; the merge publishes the release and sets the tag $tag."
fi
gh pr merge "$branch" --squash --auto --delete-branch
await_merge "$branch"
sha=$(gh pr view "$branch" --json mergeCommit --jq '.mergeCommit.oid')
leave_branch "$branch"

echo "==> waiting for the release run of $tag ($workflow on $sha)"
run=""
for _ in $(seq 1 30); do
run=$(gh run list --workflow "$workflow" --commit "$sha" --json databaseId,event \
--jq 'map(select(.event == "push")) | .[0].databaseId // empty')
[ -n "$run" ] && break
sleep 5
done
[ -n "$run" ] || die "no run of $workflow started for $sha: check GitHub Actions"
gh run watch "$run" --exit-status --interval "$wait_s" >/dev/null ||
die "the release run $run of $tag failed: gh run view $run --log-failed (a re-run is safe)"
git fetch --quiet origin tag "$tag"
echo "==> $tag published and tagged by CI (run $run)"
}

case "${1:-}" in
merge) shift; cmd_merge "$@" ;;
release) shift; cmd_release "$@" ;;
*) die "usage: pr.sh merge|release ..." ;;
esac
Loading
Loading