Cut new release candidate
Contract
| Field |
Bound contract |
| Trigger |
User asks to cut, trigger, or start a release candidate for a release branch. |
| Authority |
Remote: dispatches the release workflow on the named branch and posts one status notification; requires explicit human invocation. Preview the target and consequence before each remote mutation; mutate only the one workflow dispatch on the branch the user named and the one status notification. |
| Side effect |
Runs the configured release workflow on the release branch, obtains its run URL, and posts one status notification. Nothing else changes. |
| Done |
The workflow dispatch exits zero, the run query returns a non-null run URL, and the status notification is posted. |
Inputs
Required from the user:
- The release branch name. Strip a leading
origin/ before validating.
- Optionally, a notification destination (channel, thread, or recipient). Ask before posting if no destination was supplied.
The operator environment must supply these values before any step:
INTERNAL_REPO: the owner/repo containing the release branches and workflow.
REPO_DIR: the absolute path to its local checkout.
RC_WORKFLOW_NAME: the release-candidate workflow name. Default: Cut New Release Candidate.
RELEASE_CHANNELS: space-separated channel names. Default: preview stable.
RELEASE_BRANCH_PREFIX: per-channel branch prefix template, with {channel} replaced by each channel name. Default: {channel}_release/.
NOTIFICATION_TOKEN: a token for the notification service with write scope. Read it only from the environment; never hardcode or commit it.
NOTIFICATION_ENDPOINT: the API endpoint for posting the status notification.
If INTERNAL_REPO or NOTIFICATION_TOKEN is unset, stop and ask before running. Required tools: git, an authenticated gh, jq, and curl. Run all git and gh operations inside the release repo.
Procedure
Validate the branch at the trust boundary: set the requested branch, strip the origin/ prefix, and reject any branch that does not start with a configured channel prefix:
BRANCH_NAME="<release_branch>"
case "$BRANCH_NAME" in origin/*) BRANCH_NAME="${BRANCH_NAME#origin/}";; esac
is_release=0
for ch in $RELEASE_CHANNELS; do
prefix=$(printf '%s' "$RELEASE_BRANCH_PREFIX" | sed "s|{channel}|$ch|")
case "$BRANCH_NAME" in
"$prefix"*) is_release=1; break;;
esac
done
[ "$is_release" = 1 ] || { echo "Not a release branch: $BRANCH_NAME"; exit 1; }
Done when: the branch passes the channel-prefix check or the run stops with Not a release branch.
Preview the mutation: state INTERNAL_REPO, $RC_WORKFLOW_NAME, $BRANCH_NAME, and the consequence, a release-candidate build starts on that branch and one status notification is posted. Proceed only on the user's explicit invocation. Done when: the mutation preview is stated and the user explicitly invokes the run.
Enter the repo context: cd "$REPO_DIR". If REPO_DIR is unset or the path does not exist, ask the user for the local path to the release repo checkout and cd there; stop if none is given. Done when: the working directory is inside the release repo checkout.
Confirm the branch exists on origin before dispatching:
git fetch origin
git ls-remote --exit-code --heads origin "$BRANCH_NAME" >/dev/null
Done when: the branch is confirmed to exist on origin.
Dispatch the workflow by name on the branch ref. Record the UTC dispatch timestamp immediately before dispatching; step 6 uses it to tell this dispatch's run from the previous one:
DISPATCH_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
gh workflow run "$RC_WORKFLOW_NAME" --repo "$INTERNAL_REPO" --ref "$BRANCH_NAME"
Done when: the workflow dispatch exits zero and the dispatch timestamp is recorded.
Fetch this dispatch's run and share its url, status, and conclusion; do not watch or wait for completion. A run list taken right after dispatch can still show the previous run, so poll until the newest run's createdAt is at or after the dispatch timestamp (ISO-8601 UTC strings compare lexicographically), up to ten tries ten seconds apart:
RUN_JSON=""
for i in $(seq 1 10); do
RUN_JSON=$(gh run list \
--repo "$INTERNAL_REPO" \
--workflow "$RC_WORKFLOW_NAME" \
--branch "$BRANCH_NAME" \
--limit 1 \
--json url,status,conclusion,createdAt \
--jq '.[0] // empty')
CREATED=$(jq -r '.createdAt // empty' <<<"$RUN_JSON")
if [ -n "$CREATED" ] && [ ! "$CREATED" \< "$DISPATCH_TS" ]; then
break
fi
RUN_JSON=""
sleep 10
done
RUN_URL=$(jq -r '.url // empty' <<<"$RUN_JSON")
Guards: // empty keeps an empty run list from printing the literal null, which would otherwise pass -n and compare greater than any timestamp; the ! ... \< comparison accepts a run created in the same second as the dispatch. A run whose createdAt predates the dispatch timestamp is the previous run, not this one. After the loop, verify RUN_URL is non-empty and not null before posting anything: an empty or null URL means this dispatch's run never appeared, which is the "Dispatched but no run URL" failure below, never a notification. Done when: the URL, status, and conclusion for this dispatch's run are fetched and shared.
Post the status notification carrying the branch name and run URL. Use the destination the user named. If the user gave no destination, stop and ask for one. Send the notification through the configured endpoint:
if [ -z "$RUN_URL" ] || [ "$RUN_URL" = "null" ]; then
printf 'Blocked: no run URL for workflow %s on branch %s after dispatch at %s\n' \
"$RC_WORKFLOW_NAME" "$BRANCH_NAME" "$DISPATCH_TS" >&2
exit 1
fi
JSON=$(jq -n --arg destination "$DESTINATION" --arg wf "$RC_WORKFLOW_NAME" --arg br "$BRANCH_NAME" --arg url "$RUN_URL" '{destination: $destination, text: ("Triggered " + $wf + " for " + $br + ".\nRun: " + $url)}')
curl -s -X POST "$NOTIFICATION_ENDPOINT" \
-H "Authorization: Bearer $NOTIFICATION_TOKEN" \
-H "Content-type: application/json; charset=utf-8" \
-d "$JSON"
Require a success response from the notification API. Done when: the notification post returns success.
Failure and recovery
- Non-release branch: the prefix check in step 1 exits, stop before any remote call, report
Not a release branch: $BRANCH_NAME, and ask for a release branch. Nothing was mutated.
- Missing repo context:
REPO_DIR is unset or missing and the user supplies no checkout path, stop; nothing was mutated.
- Branch absent on origin:
git ls-remote --exit-code exits non-zero, do not dispatch; report the branch was not found on origin, ask the user to confirm the exact branch name, and re-run step 4.
gh authentication failure: the dispatch or run query fails with an auth error, direct the user to gh auth status and gh auth login, and retry the failed step after they authenticate.
- Dispatched but no run URL: the run query returns nothing or a null
url, never re-dispatch, because a second dispatch cuts a duplicate release candidate; report the workflow name, branch, and missing URL, and classify blocked for manual inspection.
- Notification post fails: the dispatch and run URL still stand; report the partial result, fix the destination or
NOTIFICATION_TOKEN, and repost. Done is not claimed until the post succeeds.
Report partial results exactly: name which of dispatch, run URL, and notification post landed. Never swallow an error or claim done while any of the three is missing.
Output
A report naming the branch, the workflow name, the run URL, the queried status and conclusion at fetch time (completion is not awaited), the notification destination posted, and the exact message text. Terminal classification: done only when the dispatch exited zero, the run URL is non-null, and the notification response was success; otherwise blocked with the failing step and the partial state.
1---2name: cut-new-release-candidate3description: Use when the user asks to cut, trigger, or start a release candidate for a release branch. Not for full releases, hotfixes, or non-release-candidate workflow dispatches.4---56# Cut new release candidate78## Contract910| Field | Bound contract |11|---|---|12| Trigger | User asks to cut, trigger, or start a release candidate for a release branch. |13| Authority | Remote: dispatches the release workflow on the named branch and posts one status notification; requires explicit human invocation. Preview the target and consequence before each remote mutation; mutate only the one workflow dispatch on the branch the user named and the one status notification. |14| Side effect | Runs the configured release workflow on the release branch, obtains its run URL, and posts one status notification. Nothing else changes. |15| Done | The workflow dispatch exits zero, the run query returns a non-null run URL, and the status notification is posted. |1617## Inputs1819Required from the user:2021- The release branch name. Strip a leading `origin/` before validating.22- Optionally, a notification destination (channel, thread, or recipient). Ask before posting if no destination was supplied.2324The operator environment must supply these values before any step:2526- `INTERNAL_REPO`: the `owner/repo` containing the release branches and workflow.27- `REPO_DIR`: the absolute path to its local checkout.28- `RC_WORKFLOW_NAME`: the release-candidate workflow name. Default: `Cut New Release Candidate`.29- `RELEASE_CHANNELS`: space-separated channel names. Default: `preview stable`.30- `RELEASE_BRANCH_PREFIX`: per-channel branch prefix template, with `{channel}` replaced by each channel name. Default: `{channel}_release/`.31- `NOTIFICATION_TOKEN`: a token for the notification service with write scope. Read it only from the environment; never hardcode or commit it.32- `NOTIFICATION_ENDPOINT`: the API endpoint for posting the status notification.3334If `INTERNAL_REPO` or `NOTIFICATION_TOKEN` is unset, stop and ask before running. Required tools: `git`, an authenticated `gh`, `jq`, and `curl`. Run all git and gh operations inside the release repo.3536## Procedure37381. Validate the branch at the trust boundary: set the requested branch, strip the `origin/` prefix, and reject any branch that does not start with a configured channel prefix:3940 ```bash41 BRANCH_NAME="<release_branch>"42 case "$BRANCH_NAME" in origin/*) BRANCH_NAME="${BRANCH_NAME#origin/}";; esac43 is_release=044 for ch in $RELEASE_CHANNELS; do45 prefix=$(printf '%s' "$RELEASE_BRANCH_PREFIX" | sed "s|{channel}|$ch|")46 case "$BRANCH_NAME" in47 "$prefix"*) is_release=1; break;;48 esac49 done50 [ "$is_release" = 1 ] || { echo "Not a release branch: $BRANCH_NAME"; exit 1; }51 ```5253 Done when: the branch passes the channel-prefix check or the run stops with `Not a release branch`.54552. Preview the mutation: state `INTERNAL_REPO`, `$RC_WORKFLOW_NAME`, `$BRANCH_NAME`, and the consequence, a release-candidate build starts on that branch and one status notification is posted. Proceed only on the user's explicit invocation. Done when: the mutation preview is stated and the user explicitly invokes the run.563. Enter the repo context: `cd "$REPO_DIR"`. If `REPO_DIR` is unset or the path does not exist, ask the user for the local path to the release repo checkout and `cd` there; stop if none is given. Done when: the working directory is inside the release repo checkout.574. Confirm the branch exists on origin before dispatching:5859 ```bash60 git fetch origin61 git ls-remote --exit-code --heads origin "$BRANCH_NAME" >/dev/null62 ```6364 Done when: the branch is confirmed to exist on origin.65665. Dispatch the workflow by name on the branch ref. Record the UTC dispatch timestamp immediately before dispatching; step 6 uses it to tell this dispatch's run from the previous one:6768 ```bash69 DISPATCH_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)70 gh workflow run "$RC_WORKFLOW_NAME" --repo "$INTERNAL_REPO" --ref "$BRANCH_NAME"71 ```7273 Done when: the workflow dispatch exits zero and the dispatch timestamp is recorded.74756. Fetch this dispatch's run and share its `url`, `status`, and `conclusion`; do not watch or wait for completion. A run list taken right after dispatch can still show the previous run, so poll until the newest run's `createdAt` is at or after the dispatch timestamp (ISO-8601 UTC strings compare lexicographically), up to ten tries ten seconds apart:7677 ```bash78 RUN_JSON=""79 for i in $(seq 1 10); do80 RUN_JSON=$(gh run list \81 --repo "$INTERNAL_REPO" \82 --workflow "$RC_WORKFLOW_NAME" \83 --branch "$BRANCH_NAME" \84 --limit 1 \85 --json url,status,conclusion,createdAt \86 --jq '.[0] // empty')87 CREATED=$(jq -r '.createdAt // empty' <<<"$RUN_JSON")88 if [ -n "$CREATED" ] && [ ! "$CREATED" \< "$DISPATCH_TS" ]; then89 break90 fi91 RUN_JSON=""92 sleep 1093 done94 RUN_URL=$(jq -r '.url // empty' <<<"$RUN_JSON")95 ```9697 Guards: `// empty` keeps an empty run list from printing the literal `null`, which would otherwise pass `-n` and compare greater than any timestamp; the `! ... \<` comparison accepts a run created in the same second as the dispatch. A run whose `createdAt` predates the dispatch timestamp is the previous run, not this one. After the loop, verify `RUN_URL` is non-empty and not `null` before posting anything: an empty or `null` URL means this dispatch's run never appeared, which is the "Dispatched but no run URL" failure below, never a notification. Done when: the URL, `status`, and `conclusion` for this dispatch's run are fetched and shared.98997. Post the status notification carrying the branch name and run URL. Use the destination the user named. If the user gave no destination, stop and ask for one. Send the notification through the configured endpoint:100101 ```bash102 if [ -z "$RUN_URL" ] || [ "$RUN_URL" = "null" ]; then103 printf 'Blocked: no run URL for workflow %s on branch %s after dispatch at %s\n' \104 "$RC_WORKFLOW_NAME" "$BRANCH_NAME" "$DISPATCH_TS" >&2105 exit 1106 fi107 JSON=$(jq -n --arg destination "$DESTINATION" --arg wf "$RC_WORKFLOW_NAME" --arg br "$BRANCH_NAME" --arg url "$RUN_URL" '{destination: $destination, text: ("Triggered " + $wf + " for " + $br + ".\nRun: " + $url)}')108 curl -s -X POST "$NOTIFICATION_ENDPOINT" \109 -H "Authorization: Bearer $NOTIFICATION_TOKEN" \110 -H "Content-type: application/json; charset=utf-8" \111 -d "$JSON"112 ```113114 Require a success response from the notification API. Done when: the notification post returns success.115116## Failure and recovery117118- Non-release branch: the prefix check in step 1 exits, stop before any remote call, report `Not a release branch: $BRANCH_NAME`, and ask for a release branch. Nothing was mutated.119- Missing repo context: `REPO_DIR` is unset or missing and the user supplies no checkout path, stop; nothing was mutated.120- Branch absent on origin: `git ls-remote --exit-code` exits non-zero, do not dispatch; report the branch was not found on `origin`, ask the user to confirm the exact branch name, and re-run step 4.121- `gh` authentication failure: the dispatch or run query fails with an auth error, direct the user to `gh auth status` and `gh auth login`, and retry the failed step after they authenticate.122- Dispatched but no run URL: the run query returns nothing or a null `url`, never re-dispatch, because a second dispatch cuts a duplicate release candidate; report the workflow name, branch, and missing URL, and classify blocked for manual inspection.123- Notification post fails: the dispatch and run URL still stand; report the partial result, fix the destination or `NOTIFICATION_TOKEN`, and repost. Done is not claimed until the post succeeds.124125Report partial results exactly: name which of dispatch, run URL, and notification post landed. Never swallow an error or claim done while any of the three is missing.126127## Output128129A report naming the branch, the workflow name, the run URL, the queried `status` and `conclusion` at fetch time (completion is not awaited), the notification destination posted, and the exact message text. Terminal classification: `done` only when the dispatch exited zero, the run URL is non-null, and the notification response was success; otherwise `blocked` with the failing step and the partial state.