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
1 change: 0 additions & 1 deletion .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ jobs:
scripts/test-go-cli-toolchain.sh
scripts/test-resolve-native-cli-release-target.sh
scripts/test-resolve-dispatcher-release-target.sh
scripts/test-is-release-please-release-commit.sh
scripts/test-release-please-config.sh
scripts/test-protocol-minimum-version-workflow.sh
scripts/test-security-scan-workflow.sh
Expand Down
14 changes: 7 additions & 7 deletions .github/workflows/native-cli-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -456,13 +456,6 @@ jobs:
fetch-depth: 0
persist-credentials: false

- name: Dispatch release-please after native CLI publish
if: needs.build.outputs.should_publish == 'true' && github.event_name == 'push'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_BRANCH: ${{ github.ref_name }}
run: gh workflow run release-please.yml --ref "${TARGET_BRANCH}" -f branch="${TARGET_BRANCH}"

- name: Mark release PR as tagged
if: github.event_name == 'push'
env:
Expand All @@ -472,3 +465,10 @@ jobs:
TARGET_BRANCH: ${{ github.ref_name }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: scripts/mark-release-pr-tagged.sh

- name: Dispatch release-please after native CLI publish
if: needs.build.outputs.should_publish == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_BRANCH: ${{ github.ref_name }}
run: gh workflow run release-please.yml --ref "${TARGET_BRANCH}" -f branch="${TARGET_BRANCH}"
37 changes: 21 additions & 16 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,28 @@ jobs:
echo "branch=${TARGET_BRANCH}" >> "$GITHUB_OUTPUT"
echo "sha=${TARGET_SHA}" >> "$GITHUB_OUTPUT"

- name: 🧭 Detect release commit
- name: Setup Go for release automation
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
with:
go-version-file: cli/.go-version
cache-dependency-path: '**/go.sum'

# release-please rebuilds every release pull request from the manifest at
# HEAD and looks the previous release up by tag. While a version the
# manifest names has no published release yet (a merged release pull
# request whose publish is still pending), that rebuild would carry the
# previous changelog entries again (#1088), so the run waits until every
# named release is out. The publish workflows dispatch this workflow once
# their release is published, which is what lets the remaining release
# pull requests be rebuilt after a release commit.
- name: 🧭 Decide whether every manifest release is published
id: release_commit
run: |
COMMIT_SUBJECT="$(git log -1 --format=%s)"
if scripts/is-release-please-release-commit.sh "${COMMIT_SUBJECT}"; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Release commit detected; release-please will wait for the next non-release commit."
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
working-directory: cli/release-automation
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
TARGET_SHA: ${{ steps.target.outputs.sha }}
run: go run ./cmd/check-release-please-gate --repo "${GITHUB_REPOSITORY}" --ref "${TARGET_SHA}" >> "$GITHUB_OUTPUT"

# ── Debug: Show repository structure ─────────────────────────
- name: 🕵️ Show file tree
Expand Down Expand Up @@ -90,13 +102,6 @@ jobs:
skip-github-release: true
token: ${{ secrets.GITHUB_TOKEN }}

- name: Setup Go for release PR automation
if: steps.target.outputs.branch == 'main' && steps.release_commit.outputs.skip != 'true' && steps.release.outputs.prs_created == 'true'
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
with:
go-version-file: cli/.go-version
cache-dependency-path: '**/go.sum'

- name: Dispatch release PR checks
id: release_pr_checks
if: steps.target.outputs.branch == 'main' && steps.release_commit.outputs.skip != 'true' && steps.release.outputs.prs_created == 'true'
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/unity-package-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,10 @@ jobs:
--asset "$RUNNER_TEMP/signed/$ASSET_NAME"

# Marks the package release pull request tagged, which is what lets
# release-please open the next release pull requests. It is marked here
# because release-please skips its own label sync while the release commit is
# still the newest main commit, which it is whenever the release was due as
# soon as the pull request was merged.
# release-please open the next release pull requests. release-please runs
# only once every release the manifest names is published, so the label is
# set here first; the release-please run this job then starts finds the pull
# request already tagged.
post-publish:
needs: [plan, publish]
runs-on: ubuntu-latest
Expand Down
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,9 @@ inputs outside the package roots (non-test `cli/common/**` sources, `scripts/ins
run in the same PR; description-only catalog regenerations are exempt. CI (`check-release-triggers`) fails otherwise. Rules and rationale:
`docs/shared-release-inputs.md`.

release-please opens one release pull request per component; merge the dispatcher one first. The
unity-package one stays draft and is merged only by automation — never merge it by hand. Details:
release-please opens one release pull request per component; merge the dispatcher one first, then
the project runner one. The unity-package one stays draft and is merged only by automation once both
are published — never merge it by hand. Details:
`docs/dispatcher-pin-release-order.md`.

## Broken CLI Releases
Expand Down
18 changes: 18 additions & 0 deletions cli/release-automation/cmd/check-release-please-gate/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package main

import (
"context"
"os"
"time"

"github.com/hatayama/unity-cli-loop/tools/release-automation/internal/automation"
)

const releasePleaseGateCheckTimeout = 2 * time.Minute

func main() {
ctx, cancel := context.WithTimeout(context.Background(), releasePleaseGateCheckTimeout)
exitCode := automation.RunReleasePleaseGateCheck(ctx, os.Stdout, os.Stderr, os.Args[1:])
cancel()
os.Exit(exitCode)
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type packagePinConsistencyConfig struct {

type packagePinConsistencyPin struct {
DispatcherReleaseTag string `json:"dispatcherReleaseTag"`
ProjectRunnerVersion string `json:"projectRunnerVersion"`
}

// RunPackagePinConsistencyCheck fails when a ref would release the Unity
Expand Down
111 changes: 107 additions & 4 deletions cli/release-automation/internal/automation/package_release_pr_gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/url"
"strings"
)

const dispatcherReleasePRComponent = "dispatcher"
const (
dispatcherReleasePRComponent = "dispatcher"
projectRunnerReleasePRComponent = "uloop-project-runner"
)

// githubFileContentAtRef reads a repository file through the contents API at an
// exact ref. The merge gate reasons about commits that are not in this
Expand Down Expand Up @@ -92,7 +96,31 @@ func openDispatcherReleasePullRequestExists(
repository string,
baseBranch string,
) (bool, error) {
headBranch := "release-please--branches--" + baseBranch + "--components--" + dispatcherReleasePRComponent
return openReleasePullRequestExists(ctx, runOutput, repository, baseBranch, dispatcherReleasePRComponent)
}

// openProjectRunnerReleasePullRequestExists reports whether a project runner
// release is still pending. Its release pull request raises the runner the
// package pins, so a package cut before it ships the previous runner.
func openProjectRunnerReleasePullRequestExists(
ctx context.Context,
runOutput func(context.Context, string, ...string) (string, error),
repository string,
baseBranch string,
) (bool, error) {
return openReleasePullRequestExists(ctx, runOutput, repository, baseBranch, projectRunnerReleasePRComponent)
}

// openReleasePullRequestExists reports whether the release pull request of one
// component is open, found by the head branch release-please names after it.
func openReleasePullRequestExists(
ctx context.Context,
runOutput func(context.Context, string, ...string) (string, error),
repository string,
baseBranch string,
component string,
) (bool, error) {
headBranch := "release-please--branches--" + baseBranch + "--components--" + component
output, err := runOutput(
ctx,
"gh",
Expand All @@ -116,8 +144,83 @@ func openDispatcherReleasePullRequestExists(
releasePRs := []mergePackageReleasePullRequest{}
err = json.Unmarshal([]byte(output), &releasePRs)
if err != nil {
return false, fmt.Errorf("%s: failed to parse dispatcher release PR list: %w",
mergePackageReleasePRCommandName, err)
return false, fmt.Errorf("%s: failed to parse %s release PR list: %w",
mergePackageReleasePRCommandName, component, err)
}
return len(releasePRs) > 0, nil
}

// releasePublished reports whether a GitHub release exists for the tag and is
// no longer a draft. A missing release reads as unpublished, but any other
// failure is returned: reading an API outage as "unpublished" would hold a
// release back for good without anyone being told.
func releasePublished(
ctx context.Context,
runOutput func(context.Context, string, ...string) (string, error),
repository string,
tag string,
) (bool, error) {
output, err := runOutput(ctx, "gh", "release", "view", tag, "--repo", repository, "--json", "isDraft")
if err != nil {
if releaseNotFound(err) {
return false, nil
}
return false, err
}

view := struct {
IsDraft bool `json:"isDraft"`
}{}
err = json.Unmarshal([]byte(output), &view)
if err != nil {
return false, fmt.Errorf("%s: failed to parse release %s: %w", mergePackageReleasePRCommandName, tag, err)
}
return !view.IsDraft, nil
}

func releaseNotFound(err error) bool {
message := err.Error()
return strings.Contains(message, "release not found") ||
strings.Contains(message, "HTTP 404") ||
strings.Contains(message, "Not Found")
}

// projectRunnerReleaseGate holds the package release pull request until the
// project runner its pin names is released. Holding settles the pass rather
// than polling: the runner is published by another, approval-gated workflow,
// whose post-publish dispatches release-please, and that run's merge step
// decides again.
func projectRunnerReleaseGate(
ctx context.Context,
stdout io.Writer,
stderr io.Writer,
config mergePackageReleasePRConfig,
releasePR mergePackageReleasePullRequest,
pin packagePinConsistencyPin,
deps mergePackageReleasePRDeps,
) (held bool, exitCode int) {
runnerPRIsOpen, err := openProjectRunnerReleasePullRequestExists(ctx, deps.runOutput, config.repository, config.baseBranch)
if err != nil {
writeMergePackageReleasePRLine(stderr, err)
return true, 1
}
if runnerPRIsOpen {
writeMergePackageReleasePRLine(stdout, fmt.Sprintf(
"PR #%d stays draft: a project runner release pull request is open; the project runner must be released first so the package pins it.",
releasePR.Number))
return true, 0
}

published, err := releasePublished(ctx, deps.runOutput, config.repository, projectRunnerReleaseTagPrefix+pin.ProjectRunnerVersion)
if err != nil {
writeMergePackageReleasePRLine(stderr, err)
return true, 1
}
if !published {
writeMergePackageReleasePRLine(stdout, fmt.Sprintf(
"PR #%d head %s pins project runner %s, which is not published yet; waiting for the project runner release.",
releasePR.Number, releasePR.HeadRefOID, pin.ProjectRunnerVersion))
return true, 0
}
return false, 0
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"flag"
"fmt"
"io"
"strings"
"time"
)

Expand Down Expand Up @@ -259,16 +260,21 @@ func attemptMergePackageReleasePR(
return mergePackageReleasePRPassSettled, 1
}

pinnedTag, err := packageReleasePullRequestPinnedTag(ctx, config, releasePR, deps)
pin, err := packageReleasePullRequestPin(ctx, config, releasePR, deps)
if err != nil {
writeMergePackageReleasePRLine(stderr, err)
return mergePackageReleasePRPassSettled, 1
}
pinnedTag := pin.DispatcherReleaseTag
if pinnedTag != dispatcherTag {
writeMergePackageReleasePRLine(stdout, fmt.Sprintf(
"PR #%d head %s still pins %s; waiting for release-please to rebase it onto the stamp.",
releasePR.Number, releasePR.HeadRefOID, pinnedTag))
return mergePackageReleasePRPassAwaitingPin, 0
return stalePinOutcome(ctx, stdout, stderr, config, releasePR, pinnedTag, deps)
}
// The runner is judged only on a head that records the current dispatcher:
// a head from before the stamp may still name an older runner, and
// release-please rebuilds it before this pass can merge anyway.
held, exitCode := projectRunnerReleaseGate(ctx, stdout, stderr, config, releasePR, pin, deps)
if held {
return mergePackageReleasePRPassSettled, exitCode
}
// The draft flag is not consulted as evidence: it exists to keep people from
// merging this pull request, not to prove its checks ran. What proves that
Expand All @@ -285,6 +291,40 @@ func attemptMergePackageReleasePR(
return readyAndMergePackageReleasePR(ctx, stdout, stderr, config, releasePR, pinnedTag, deps)
}

// stalePinOutcome decides a pass whose head still pins an older dispatcher.
// Waiting assumes release-please will rebase the head onto the stamp, which it
// does not do while a release main's manifest names is unpublished, so the
// wait path settles then instead of timing out. The no-wait path runs inside a
// release-please run that the gate let through, so it never reads the manifest
// here.
func stalePinOutcome(
ctx context.Context,
stdout io.Writer,
stderr io.Writer,
config mergePackageReleasePRConfig,
releasePR mergePackageReleasePullRequest,
pinnedTag string,
deps mergePackageReleasePRDeps,
) (mergePackageReleasePRPassOutcome, int) {
if config.waitForMergeable {
unpublished, err := unpublishedManifestReleases(ctx, deps.runOutput, config.repository, config.baseBranch)
if err != nil {
writeMergePackageReleasePRLine(stderr, err)
return mergePackageReleasePRPassSettled, 1
}
if len(unpublished) > 0 {
writeMergePackageReleasePRLine(stdout, fmt.Sprintf(
"PR #%d head %s still pins %s, and release-please will not rebuild it until %s is published; that publish dispatches release-please, whose merge step decides again.",
releasePR.Number, releasePR.HeadRefOID, pinnedTag, strings.Join(unpublished, ", ")))
return mergePackageReleasePRPassSettled, 0
}
}
writeMergePackageReleasePRLine(stdout, fmt.Sprintf(
"PR #%d head %s still pins %s; waiting for release-please to rebase it onto the stamp.",
releasePR.Number, releasePR.HeadRefOID, pinnedTag))
return mergePackageReleasePRPassAwaitingPin, 0
}

func findPackageReleasePullRequest(
ctx context.Context,
config mergePackageReleasePRConfig,
Expand Down Expand Up @@ -338,32 +378,36 @@ func findPackageReleasePullRequest(
return matchingPRs[0], true, nil
}

// packageReleasePullRequestPinnedTag reads the pin at the pull request head
// rather than in the checkout: the merge decision is about the commit that will
// carry the package tag, not about the workspace this command runs in.
func packageReleasePullRequestPinnedTag(
// packageReleasePullRequestPin reads the pin at the pull request head rather
// than in the checkout: the merge decision is about the commit that will carry
// the package tag, not about the workspace this command runs in.
func packageReleasePullRequestPin(
ctx context.Context,
config mergePackageReleasePRConfig,
releasePR mergePackageReleasePullRequest,
deps mergePackageReleasePRDeps,
) (string, error) {
) (packagePinConsistencyPin, error) {
decoded, err := githubFileContentAtRef(
ctx, deps.runOutput, config.repository, unityPackageCliPinFile, releasePR.HeadRefOID)
if err != nil {
return "", err
return packagePinConsistencyPin{}, err
}

pin := packagePinConsistencyPin{}
err = json.Unmarshal(decoded, &pin)
if err != nil {
return "", fmt.Errorf("%s: failed to parse %s at %s: %w",
return packagePinConsistencyPin{}, fmt.Errorf("%s: failed to parse %s at %s: %w",
mergePackageReleasePRCommandName, unityPackageCliPinFile, releasePR.HeadRefOID, err)
}
if pin.DispatcherReleaseTag == "" {
return "", fmt.Errorf("%s: %s at %s has no dispatcherReleaseTag",
return packagePinConsistencyPin{}, fmt.Errorf("%s: %s at %s has no dispatcherReleaseTag",
mergePackageReleasePRCommandName, unityPackageCliPinFile, releasePR.HeadRefOID)
}
if pin.ProjectRunnerVersion == "" {
return packagePinConsistencyPin{}, fmt.Errorf("%s: %s at %s has no projectRunnerVersion",
mergePackageReleasePRCommandName, unityPackageCliPinFile, releasePR.HeadRefOID)
}
return pin.DispatcherReleaseTag, nil
return pin, nil
}

// packageReleasePullRequestChecksPassed requires a completed successful run of
Expand Down
Loading
Loading