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
4 changes: 4 additions & 0 deletions .github/workflows/dispatcher-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,10 @@ jobs:
# not earlier. The App token is required: a merge made with GITHUB_TOKEN
# starts no follow-up workflow run, so the package release would never be
# tagged (docs/adr/0007-separate-release-prs-and-package-auto-merge.md).
# The package release pull request stays draft until this step lifts the
# draft itself, so draft is not a reason to wait here. An already open
# release pull request for the next dispatcher cycle is not a reason to
# wait either: this package pin records the dispatcher published above.
- name: Merge the Unity package release pull request now that the pin records this dispatcher
if: needs.build.outputs.should_publish == 'true' && needs.build.outputs.release_prerelease != 'true'
working-directory: cli/release-automation
Expand Down
39 changes: 39 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,49 @@ jobs:
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.package_release_sync.outputs.ready != 'false' && steps.release.outputs.prs_created == 'true'
working-directory: cli/release-automation
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_BRANCH: ${{ steps.target.outputs.branch }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: go run ./cmd/dispatch-release-please-pr-checks

# A merge made with GITHUB_TOKEN starts no follow-up workflow run, so the
# package release would never be tagged; the App token is what makes the
# merge observable to release-please
# (docs/adr/0007-separate-release-prs-and-package-auto-merge.md).
# The dispatch step above fails when any component's checks fail, and a
# failing runner release must not strand a package-only release, so these
# steps also run after that failure. The merge helper verifies the package
# head's own runs, so another component's failure cannot make it merge
# something unchecked.
- name: Mint package release merge token
id: package-merge-token
if: steps.target.outputs.branch == 'main' && steps.release_commit.outputs.skip != 'true' && steps.package_release_sync.outputs.ready != 'false' && steps.release.outputs.prs_created == 'true' && !cancelled() && (steps.release_pr_checks.outcome == 'success' || steps.release_pr_checks.outcome == 'failure')
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
app-id: ${{ vars.DISPATCHER_PIN_APP_ID }}
private-key: ${{ secrets.DISPATCHER_PIN_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write

# This step leaves the package release pull request draft while a
# dispatcher release pull request is open, and while the pin at its head
# still disagrees with the dispatcher version the base branch manifest
# releases. Both windows would otherwise ship a package that pins an
# older dispatcher than the one main is about to release.
- name: Merge the Unity package release pull request when no dispatcher release is pending
if: steps.target.outputs.branch == 'main' && steps.release_commit.outputs.skip != 'true' && steps.package_release_sync.outputs.ready != 'false' && steps.release.outputs.prs_created == 'true' && !cancelled() && (steps.release_pr_checks.outcome == 'success' || steps.release_pr_checks.outcome == 'failure')
working-directory: cli/release-automation
env:
GH_TOKEN: ${{ steps.package-merge-token.outputs.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
TARGET_BRANCH: ${{ steps.target.outputs.branch }}
run: >-
go run ./cmd/merge-package-release-pr
--repo "${GITHUB_REPOSITORY}"
--base-branch "${TARGET_BRANCH}"
--require-no-open-dispatcher-pr
--no-wait
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ 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, and
leave the unity-package one to be merged by `post-publish` after the pin stamp. Details:
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:
`docs/dispatcher-pin-release-order.md`.

## Broken CLI Releases
Expand Down
123 changes: 123 additions & 0 deletions cli/release-automation/internal/automation/package_release_pr_gate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package automation

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"strings"
)

const dispatcherReleasePRComponent = "dispatcher"

// githubFileContentAtRef reads a repository file through the contents API at an
// exact ref. The merge gate reasons about commits that are not in this
// checkout, so the file has to come from GitHub rather than from the worktree.
func githubFileContentAtRef(
ctx context.Context,
runOutput func(context.Context, string, ...string) (string, error),
repository string,
path string,
ref string,
) ([]byte, error) {
// The ref is a query parameter, so an unescaped one would be cut short at
// the first & and read a different commit than the caller asked for.
output, err := runOutput(
ctx,
"gh",
"api",
"repos/"+repository+"/contents/"+path+"?ref="+url.QueryEscape(ref),
"--jq",
".content",
)
if err != nil {
return nil, err
}

// The contents API wraps base64 at a fixed width, so every whitespace
// character has to go before decoding.
encoded := strings.Join(strings.Fields(output), "")
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("%s: failed to decode %s at %s: %w",
mergePackageReleasePRCommandName, path, ref, err)
}
return decoded, nil
}

// dispatcherReleaseTagFromManifestAtRef reports the dispatcher release tag the
// base branch tip publishes. The package pin must record that exact tag, so
// this is the value the merge gate compares the pin against when the caller
// does not name a tag itself.
func dispatcherReleaseTagFromManifestAtRef(
ctx context.Context,
runOutput func(context.Context, string, ...string) (string, error),
repository string,
ref string,
) (string, error) {
content, err := githubFileContentAtRef(ctx, runOutput, repository, releasePleaseManifestRelativePath, ref)
if err != nil {
return "", err
}

manifest := map[string]string{}
err = json.Unmarshal(content, &manifest)
if err != nil {
return "", fmt.Errorf("%s: failed to parse %s at %s: %w",
mergePackageReleasePRCommandName, releasePleaseManifestRelativePath, ref, err)
}

version := manifest[releasePleaseDispatcherPackage]
if strings.TrimSpace(version) == "" {
return "", fmt.Errorf("%s: %s at %s has no %q version",
mergePackageReleasePRCommandName, releasePleaseManifestRelativePath, ref, releasePleaseDispatcherPackage)
}
// A padded version would build a tag no release ever carries, so the gate
// would wait out its whole timeout instead of reporting the bad manifest.
if version != strings.TrimSpace(version) {
return "", fmt.Errorf("%s: %s at %s releases %q as %q, which is not a bare version",
mergePackageReleasePRCommandName, releasePleaseManifestRelativePath, ref, releasePleaseDispatcherPackage, version)
}
return dispatcherReleaseTagPrefix + version, nil
}

// openDispatcherReleasePullRequestExists reports whether a dispatcher release
// is still pending. The label is deliberately not part of the query: a pending
// release pull request that lost its label is still a dispatcher release the
// package must not overtake.
func openDispatcherReleasePullRequestExists(
ctx context.Context,
runOutput func(context.Context, string, ...string) (string, error),
repository string,
baseBranch string,
) (bool, error) {
headBranch := "release-please--branches--" + baseBranch + "--components--" + dispatcherReleasePRComponent
output, err := runOutput(
ctx,
"gh",
"pr",
"list",
"--repo",
repository,
"--state",
"open",
"--base",
baseBranch,
"--head",
headBranch,
"--json",
"number",
)
if err != nil {
return false, err
}

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 len(releasePRs) > 0, nil
}
Loading
Loading