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
69 changes: 60 additions & 9 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
# instead; a v<version> tag anywhere else on origin is refused. A bump PR
# closed without merging is refused while release-<version> is on origin;
# once that branch is deleted, the next run starts over.
# A resumed PR must belong to origin and match its branch tip while open, or
# it is refused, even once tagged. Its tree (or a pushed branch with no PR)
# must also be exactly the version bump in Cargo.toml and Cargo.lock from
# its merge base, or it is refused before merging or tagging.
#
# Prints a progress line on stderr for each step, with the release workflow
# run's URL once it waits for that run, and the GitHub Release's URL last.
Expand Down Expand Up @@ -67,6 +71,9 @@ main() {

fetch_main
find_earlier_run
case $pr_state in
OPEN | MERGED) validate_bump_pr ;;
esac
finish_if_tagged

case $pr_state in
Expand All @@ -77,6 +84,7 @@ main() {
git push --quiet origin "$head:refs/heads/$branch"
else
progress "resuming: $branch is on origin at ${head:0:7} with no PR"
validate_bump_pr
write_summary "no PR was opened"
fi
open_pr
Expand Down Expand Up @@ -104,25 +112,68 @@ main() {
# CLOSED, or empty with no PR), url, head and merge from the newest PR from
# $branch, or with no PR, head from $branch on origin if it was pushed. A PR
# closed without merging whose branch is gone from origin counts as no PR.
# Refuses a PR from another repository, or an open PR whose head is not
# $branch on origin.
find_earlier_run() {
local pr
pr=$(gh pr list --head "$branch" --base main --state all --limit 1 \
--json state,url,headRefOid,mergeCommit \
--jq '.[0] // empty | "\(.state) \(.url) \(.headRefOid) \(.mergeCommit.oid // "")"')
read -r pr_state url head merge <<<"$pr"
local pr cross_repository origin_head
pr=$(gh pr list --repo "$(git remote get-url origin)" --head "$branch" --base main --state all --limit 1 \
--json state,url,headRefOid,mergeCommit,isCrossRepository \
--jq '.[0] // empty | "\(.isCrossRepository) \(.state) \(.url) \(.headRefOid) \(.mergeCommit.oid // "")"')
read -r cross_repository pr_state url head merge <<<"$pr"
origin_head=$(git ls-remote origin "refs/heads/$branch" | cut -f 1)
# A PR closed without merging whose branch was then deleted leaves
# nothing to resume: start over.
if [ "$pr_state" = CLOSED ] && [ -z "$(git ls-remote origin "refs/heads/$branch")" ]; then
# nothing to resume, whichever repository it came from: start over.
if [ "$pr_state" = CLOSED ] && [ -z "$origin_head" ]; then
pr_state='' url='' head='' merge=''
fi
if [ -n "$pr_state" ] && [ "$cross_repository" != false ]; then
refuse "$url has its head in another repository, not origin"
fi
if [ "$pr_state" = OPEN ] && { [ -z "$origin_head" ] || [ "$head" != "$origin_head" ]; }; then
refuse "$url head does not match refs/heads/$branch on origin"
fi
if [ -z "$pr_state" ]; then
head=$(git ls-remote origin "refs/heads/$branch" | cut -f 1)
head=$origin_head
if [ -n "$head" ]; then
git fetch --quiet origin "refs/heads/$branch"
fi
fi
}

# validate_bump_pr
# Refuses unless the selected PR's (or pushed branch's) entire tree is the
# bump_version result from its merge base. A merged PR uses main just before
# its merge, since its head is already an ancestor of main now.
validate_bump_pr() {
local base target expected candidate=${url:-$branch on origin}
if ! git fetch --quiet origin "$head"; then
refuse "$candidate head could not be fetched from origin"
fi
target=origin/main
if [ "$pr_state" = MERGED ]; then
target=$merge^1
fi
base=$(git merge-base "$head" "$target")
expected=$(bump_tree "$base")
if [ "$expected" != "$(git rev-parse "$head^{tree}")" ]; then
refuse "$candidate changes more than the version bump to $version in Cargo.toml and Cargo.lock"
fi
}

# bump_tree <base>
# The tree commit_bump would make, without committing or touching the caller's
# checkout. The subshell owns the temporary worktree and its cleanup trap.
bump_tree() (
local repo=$PWD work
work=$(mktemp -d)
trap 'git -C "$repo" worktree remove --force "$work" 2>/dev/null || rm -rf "$work"' EXIT
git worktree add --quiet --detach "$work" "$1"
cd "$work"
bump_version "$version"
git add Cargo.toml Cargo.lock
git write-tree
)

# finish_if_tagged
# After an earlier run, if $tag is on origin at the merge of the bump PR,
# waits for its release workflow run and exits as wait_for_release does, and
Expand Down Expand Up @@ -202,7 +253,7 @@ wait_and_merge() {
exit 1
fi

gh pr merge "$branch" --merge --match-head-commit "$head" >/dev/null
gh pr merge "$url" --merge --match-head-commit "$head" >/dev/null
fetch_main
merge=$(merge_commit_of "$head")
progress "merged $url as ${merge:0:7}"
Expand Down
25 changes: 19 additions & 6 deletions tests/fakes/gh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
//!
//! `gh api --method PATCH repos/<repo>/pulls/<number> -f body=<body>` sets the
//! PR's body. `gh pr view`, `gh pr ready` and `gh pr merge` name the PR by
//! its head branch or its number; numeric merge reads that PR's actual head.
//! its head branch, number or URL; a number or URL selects that PR's actual head.
//!
//! `gh issue close` closes the issue, recording its `--comment`; on an issue
//! already closed it only warns, as gh does.
Expand Down Expand Up @@ -266,9 +266,11 @@ fn check_repo(state: &Json, flags: &Flags) {

/// Fail unless `repo`, if any, is the fake's. `{owner}/{repo}` is gh's
/// placeholder for the current directory's repo, which is always the fake's.
/// The local origin path stands in for that repository's remote URL.
fn check_repo_is(state: &Json, repo: Option<&str>) {
if let Some(repo) = repo
&& repo != "{owner}/{repo}"
&& Path::new(repo) != origin_repo()
&& repo.to_lowercase() != state.at("repo").str().to_lowercase()
{
die(&format!("fake gh: unknown repo {repo}"), 1);
Expand All @@ -287,9 +289,14 @@ fn newest_pr_from(state: &Json, head: &str) -> usize {
.unwrap_or_else(|| no_pr_for(head))
}

/// Resolve a numeric identity or, for fake agents, a head branch.
/// Resolve a number or URL, or, for fake agents, a head branch.
fn selected_pr(state: &Json, name: &str) -> usize {
if !name.is_empty() && name.bytes().all(|b| b.is_ascii_digit()) {
if name.starts_with("https://") {
prs(state)
.iter()
.rposition(|pr| pr.at("url").str() == name)
.unwrap_or_else(|| no_pr_for(name))
} else if !name.is_empty() && name.bytes().all(|b| b.is_ascii_digit()) {
prs(state)
.iter()
.rposition(|pr| pr.at("number").as_i64() == name.parse().ok())
Expand Down Expand Up @@ -448,6 +455,8 @@ fn pr_fields(pr: &Json, wanted: &[String]) -> Json {
const PR_LINE_OF_NEWEST: &str =
r#".[0] // empty | "\(.state) \(.url) \(.headRefOid) \(.mergeCommit.oid // "")""#;

const PR_LINE_OF_NEWEST_WITH_OWNERSHIP: &str = r#".[0] // empty | "\(.isCrossRepository) \(.state) \(.url) \(.headRefOid) \(.mergeCommit.oid // "")""#;

const WORKFLOW_RUN_LINES: &str = r#".workflow_runs[] | "\(.status) \(.conclusion) \(.name)""#;

const COMMIT_PR_LINES: &str = r#".[] | "\(.merge_commit_sha) \(.number)""#;
Expand All @@ -460,7 +469,7 @@ const FAILED_JOB_NAMES: &str = r#"[.jobs[] | select(.conclusion != "success" and

/// Newest first. `--search head:<prefix>` matches head branches starting with
/// <prefix>, as GitHub's search does; `--head <branch>` matches that branch
/// exactly. `--jq` is supported only as `PR_LINE_OF_NEWEST`.
/// exactly. `--jq` supports the newest-PR line with or without its ownership.
fn pr_list(state: &Json, flags: &Flags) {
let wanted_state = flag(flags, "state").unwrap_or("open").to_uppercase();
let search = flag(flags, "search").unwrap_or("");
Expand All @@ -484,16 +493,20 @@ fn pr_list(state: &Json, flags: &Flags) {
.collect();
match flag(flags, "jq") {
None => println!("{}", Array(listed)),
Some(PR_LINE_OF_NEWEST) => {
Some(jq @ (PR_LINE_OF_NEWEST | PR_LINE_OF_NEWEST_WITH_OWNERSHIP)) => {
if let Some(pr) = listed.first() {
let merge = match pr.at("mergeCommit") {
commit if commit.truthy() => {
commit.get("oid").map(Json::python).unwrap_or_default()
}
_ => String::new(),
};
let ownership = match jq {
PR_LINE_OF_NEWEST_WITH_OWNERSHIP => format!("{} ", pr.at("isCrossRepository")),
_ => String::new(),
};
let line = format!(
"{} {} {} {merge}",
"{ownership}{} {} {} {merge}",
pr.at("state").python(),
pr.at("url").python(),
pr.at("headRefOid").python()
Expand Down
Loading
Loading