fix(release): verify release token capabilities
This commit is contained in:
@@ -173,6 +173,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
checks: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate trusted governance preflight context
|
||||
env:
|
||||
@@ -214,15 +215,6 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Require release governance token
|
||||
env:
|
||||
RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
|
||||
run: |
|
||||
test -n "$RELEASE_GOVERNANCE_TOKEN" || {
|
||||
echo "RELEASE_GOVERNANCE_TOKEN with repository Administration read permission is required" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Require successful CI Gate on the preflight commit
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
@@ -245,6 +237,22 @@ jobs:
|
||||
core.setFailed(`CI Gate has not succeeded for ${process.env.PREFLIGHT_COMMIT}`);
|
||||
}
|
||||
|
||||
- name: Check out trusted preflight tooling
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.governance_preflight_commit }}
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Require release governance token
|
||||
env:
|
||||
RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
|
||||
run: |
|
||||
test -n "$RELEASE_GOVERNANCE_TOKEN" || {
|
||||
echo "RELEASE_GOVERNANCE_TOKEN with repository Administration read permission is required" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Require immutable releases governance
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
@@ -265,6 +273,21 @@ jobs:
|
||||
core.setFailed("Immutable releases must be enabled before publishing");
|
||||
}
|
||||
|
||||
- name: Verify Homebrew PR automation permission
|
||||
env:
|
||||
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
|
||||
run: |
|
||||
test -n "$HOMEBREW_PR_TOKEN" || {
|
||||
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
|
||||
exit 1
|
||||
}
|
||||
test "$HOMEBREW_PR_TOKEN" != "$RELEASE_GOVERNANCE_TOKEN" || {
|
||||
echo "HOMEBREW_PR_TOKEN and RELEASE_GOVERNANCE_TOKEN must use separate least-privilege identities" >&2
|
||||
exit 1
|
||||
}
|
||||
./scripts/release/verify-homebrew-pr-token.sh "$GITHUB_REPOSITORY" --canary
|
||||
|
||||
release-contract:
|
||||
needs: [dispatch-contract, authorize-recovery]
|
||||
if: ${{ always() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success')) }}
|
||||
@@ -417,16 +440,6 @@ jobs:
|
||||
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
|
||||
'+refs/tags/v*:refs/tags/v*'
|
||||
|
||||
- name: Check Homebrew PR automation token
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
env:
|
||||
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
run: |
|
||||
if [ -z "${HOMEBREW_PR_TOKEN:-}" ]; then
|
||||
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate release contract
|
||||
id: contract
|
||||
env:
|
||||
@@ -514,6 +527,23 @@ jobs:
|
||||
core.setFailed("Immutable releases must be enabled before publishing");
|
||||
}
|
||||
|
||||
- name: Verify Homebrew PR automation permission
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
env:
|
||||
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$HOMEBREW_PR_TOKEN" ]; then
|
||||
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$HOMEBREW_PR_TOKEN" = "$RELEASE_GOVERNANCE_TOKEN" ]; then
|
||||
echo "HOMEBREW_PR_TOKEN and RELEASE_GOVERNANCE_TOKEN must use separate least-privilege identities" >&2
|
||||
exit 1
|
||||
fi
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-homebrew-pr-token.sh" \
|
||||
"$GITHUB_REPOSITORY" --canary
|
||||
|
||||
- name: Require successful beta delivery before stable promotion
|
||||
if: ${{ steps.contract.outputs.channel == 'stable' }}
|
||||
uses: actions/github-script@v7
|
||||
|
||||
+15
-6
@@ -65,16 +65,25 @@ git diff --check
|
||||
## Homebrew Formula PR Automation
|
||||
|
||||
Official tag releases require the repository Actions secret
|
||||
`HOMEBREW_PR_TOKEN`. The `DingTalk-Real-AI` organization currently does not
|
||||
allow fine-grained personal access tokens to target this repository, so use a
|
||||
classic personal access token owned by a maintainer or release-bot account with
|
||||
`HOMEBREW_PR_TOKEN`. Prefer a fine-grained personal access token owned by a
|
||||
maintainer or release-bot account, limited to this repository with
|
||||
`Contents: write` and `Pull requests: write`. If organization policy prevents
|
||||
that account from targeting the repository, use a dedicated classic token with
|
||||
only the `public_repo` scope. Do not reuse a broad developer token.
|
||||
|
||||
Store the non-expiring token as the `HOMEBREW_PR_TOKEN` repository Actions
|
||||
secret. Replace it immediately if it is exposed, its owner loses repository
|
||||
access, or the release-bot ownership changes. The Release workflow uses this
|
||||
Store the dedicated token as the `HOMEBREW_PR_TOKEN` repository Actions secret
|
||||
and rotate it before its configured expiration. Replace it immediately if it is
|
||||
exposed, its owner loses repository access, or the release-bot ownership
|
||||
changes. The Release workflow uses this
|
||||
dedicated token only to push an `automation/homebrew-*` branch and open the
|
||||
stable or beta Formula PR. It does not push Formula changes directly to `main`.
|
||||
The default-branch governance preflight and every tag contract authenticate the
|
||||
token before publication, reject over-scoped classic tokens, confirm its
|
||||
identity, and run a controlled write canary. The canary pushes a unique
|
||||
`automation/homebrew-token-canary-*` branch with a `[skip ci]` commit, creates a
|
||||
draft PR, closes it, and deletes the branch with the same token. This proves both
|
||||
Contents and Pull requests write access before publication without merging
|
||||
anything. The gate also rejects reuse of `RELEASE_GOVERNANCE_TOKEN`.
|
||||
No maintainer environment variable is required when creating a tag. Using the
|
||||
built-in `GITHUB_TOKEN` is insufficient because organization policy prevents
|
||||
Actions from creating pull requests, and its generated PR events may require
|
||||
|
||||
@@ -128,6 +128,7 @@ Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 mac
|
||||
- `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。
|
||||
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
|
||||
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
|
||||
- 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。
|
||||
- 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。
|
||||
|
||||
immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
|
||||
Executable
+254
@@ -0,0 +1,254 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
REPOSITORY="${1:-}"
|
||||
MODE="${2:-}"
|
||||
TOKEN="${HOMEBREW_PR_TOKEN:-}"
|
||||
|
||||
usage() {
|
||||
printf '%s\n' 'usage: verify-homebrew-pr-token.sh <owner/repository> [--canary]' >&2
|
||||
}
|
||||
|
||||
err() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -n "$REPOSITORY" ] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
case "$REPOSITORY" in
|
||||
*/*) ;;
|
||||
*) usage; exit 2 ;;
|
||||
esac
|
||||
case "$MODE" in
|
||||
""|--canary) ;;
|
||||
*) usage; exit 2 ;;
|
||||
esac
|
||||
[ -n "$TOKEN" ] || err "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases"
|
||||
command -v gh >/dev/null 2>&1 || err "gh is required to verify HOMEBREW_PR_TOKEN"
|
||||
|
||||
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-homebrew-token-XXXXXX")"
|
||||
CANARY_ROOT=""
|
||||
CANARY_BRANCH=""
|
||||
CANARY_COMMIT=""
|
||||
CANARY_BRANCH_MAY_EXIST="false"
|
||||
CANARY_PR_MAY_EXIST="false"
|
||||
CANARY_PR_NUMBER=""
|
||||
cleanup() {
|
||||
status=$?
|
||||
trap - EXIT INT TERM
|
||||
set +e
|
||||
cleanup_failed="false"
|
||||
if [ "$CANARY_PR_MAY_EXIST" = "true" ]; then
|
||||
cleanup_pr_number="$CANARY_PR_NUMBER"
|
||||
if [ -z "$cleanup_pr_number" ]; then
|
||||
if ! cleanup_pr_number="$(find_canary_pr_number)"; then
|
||||
printf 'error: could not inspect Homebrew token canary PR for branch %s\n' \
|
||||
"$CANARY_BRANCH" >&2
|
||||
cleanup_failed="true"
|
||||
fi
|
||||
fi
|
||||
if [ -n "$cleanup_pr_number" ] &&
|
||||
! close_canary_pr "$cleanup_pr_number"; then
|
||||
printf 'error: could not clean up Homebrew token canary PR #%s\n' \
|
||||
"$cleanup_pr_number" >&2
|
||||
cleanup_failed="true"
|
||||
fi
|
||||
fi
|
||||
if [ "$CANARY_BRANCH_MAY_EXIST" = "true" ]; then
|
||||
if ! delete_canary_branch >/dev/null 2>&1; then
|
||||
printf 'error: could not clean up Homebrew token canary branch %s\n' \
|
||||
"$CANARY_BRANCH" >&2
|
||||
cleanup_failed="true"
|
||||
fi
|
||||
fi
|
||||
if ! rm -rf "$TMP_ROOT"; then
|
||||
printf 'error: could not clean up Homebrew token canary workspace\n' >&2
|
||||
cleanup_failed="true"
|
||||
fi
|
||||
if [ "$cleanup_failed" = "true" ] && [ "$status" -eq 0 ]; then
|
||||
status=1
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
response="$TMP_ROOT/repository-response"
|
||||
if ! GH_TOKEN="$TOKEN" gh api \
|
||||
-i \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
"repos/$REPOSITORY" \
|
||||
--jq '[.full_name, (.permissions.push // false), .default_branch] | @tsv' \
|
||||
> "$response"; then
|
||||
err "HOMEBREW_PR_TOKEN could not authenticate to $REPOSITORY"
|
||||
fi
|
||||
|
||||
scopes="$(
|
||||
awk '
|
||||
tolower($0) ~ /^x-oauth-scopes:/ {
|
||||
sub(/^[^:]*:[[:space:]]*/, "")
|
||||
sub(/\r$/, "")
|
||||
print
|
||||
exit
|
||||
}
|
||||
' "$response"
|
||||
)"
|
||||
credential_kind="fine-grained"
|
||||
if [ -n "$scopes" ]; then
|
||||
[ "$scopes" = "public_repo" ] || {
|
||||
err "classic HOMEBREW_PR_TOKEN must have only public_repo scope"
|
||||
}
|
||||
credential_kind="classic-public_repo"
|
||||
fi
|
||||
|
||||
repository_state="$(tail -n 1 "$response")"
|
||||
actual_repository="$(printf '%s\n' "$repository_state" | cut -f1)"
|
||||
can_push="$(printf '%s\n' "$repository_state" | cut -f2)"
|
||||
default_branch="$(printf '%s\n' "$repository_state" | cut -f3)"
|
||||
[ "$actual_repository" = "$REPOSITORY" ] || {
|
||||
err "HOMEBREW_PR_TOKEN resolved unexpected repository $actual_repository"
|
||||
}
|
||||
[ "$can_push" = "true" ] || {
|
||||
err "HOMEBREW_PR_TOKEN owner does not have push permission to $REPOSITORY"
|
||||
}
|
||||
[ -n "$default_branch" ] || err "HOMEBREW_PR_TOKEN returned an empty default branch"
|
||||
|
||||
actor="$(
|
||||
GH_TOKEN="$TOKEN" gh api \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
user \
|
||||
--jq '.login'
|
||||
)" || err "HOMEBREW_PR_TOKEN could not resolve its owner"
|
||||
[ -n "$actor" ] || err "HOMEBREW_PR_TOKEN returned an empty owner"
|
||||
|
||||
GH_TOKEN="$TOKEN" gh api \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
"repos/$REPOSITORY/pulls?state=open&per_page=1" \
|
||||
--silent \
|
||||
|| err "HOMEBREW_PR_TOKEN cannot access pull requests in $REPOSITORY"
|
||||
|
||||
if [ "$MODE" = "--canary" ]; then
|
||||
command -v git >/dev/null 2>&1 || err "git is required to run the Homebrew token canary"
|
||||
nonce="$(basename "$TMP_ROOT" | tr -cd 'A-Za-z0-9')"
|
||||
run_id="${GITHUB_RUN_ID:-manual-$(date +%s)-$nonce}"
|
||||
run_attempt="${GITHUB_RUN_ATTEMPT:-1}"
|
||||
case "$run_id-$run_attempt" in
|
||||
*[!A-Za-z0-9_-]*) err "invalid Homebrew token canary identity" ;;
|
||||
esac
|
||||
|
||||
CANARY_BRANCH="automation/homebrew-token-canary-$run_id-$run_attempt"
|
||||
CANARY_ROOT="$TMP_ROOT/canary"
|
||||
canonical_remote="https://github.com/$REPOSITORY.git"
|
||||
askpass="$TMP_ROOT/git-askpass.sh"
|
||||
printf '%s\n' \
|
||||
'#!/bin/sh' \
|
||||
'case "$1" in' \
|
||||
' *Username*) printf "%s\n" "x-access-token" ;;' \
|
||||
' *) printf "%s\n" "$HOMEBREW_PR_TOKEN" ;;' \
|
||||
'esac' > "$askpass"
|
||||
chmod 700 "$askpass"
|
||||
export HOMEBREW_PR_TOKEN GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0
|
||||
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_SYSTEM=/dev/null GIT_CONFIG_GLOBAL=/dev/null
|
||||
export GIT_CONFIG_COUNT=0
|
||||
|
||||
git_with_homebrew_token() {
|
||||
git -c credential.helper= -c http.extraHeader= "$@"
|
||||
}
|
||||
find_canary_pr_number() {
|
||||
owner="${REPOSITORY%%/*}"
|
||||
numbers="$(
|
||||
GH_TOKEN="$TOKEN" gh api \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
"repos/$REPOSITORY/pulls?state=open&head=$owner:$CANARY_BRANCH&base=$default_branch&per_page=2" \
|
||||
--jq '.[].number'
|
||||
)" || return 1
|
||||
case "$numbers" in
|
||||
"") return 0 ;;
|
||||
*[!0-9]*) return 1 ;;
|
||||
*) printf '%s\n' "$numbers" ;;
|
||||
esac
|
||||
}
|
||||
close_canary_pr() {
|
||||
GH_TOKEN="$TOKEN" gh api \
|
||||
-X PATCH \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
"repos/$REPOSITORY/pulls/$1" \
|
||||
-f state=closed \
|
||||
--silent
|
||||
}
|
||||
delete_canary_branch() {
|
||||
[ -n "$CANARY_COMMIT" ] || return 1
|
||||
remote_ref="$(
|
||||
git_with_homebrew_token -C "$CANARY_ROOT" ls-remote \
|
||||
--heads "$canonical_remote" "refs/heads/$CANARY_BRANCH"
|
||||
)" || return 1
|
||||
[ -n "$remote_ref" ] || return 0
|
||||
remote_sha="$(printf '%s\n' "$remote_ref" | awk 'NR == 1 { print $1 }')"
|
||||
[ "$remote_sha" = "$CANARY_COMMIT" ] || return 1
|
||||
git_with_homebrew_token -C "$CANARY_ROOT" push \
|
||||
--force-with-lease="refs/heads/$CANARY_BRANCH:$CANARY_COMMIT" \
|
||||
"$canonical_remote" ":refs/heads/$CANARY_BRANCH"
|
||||
}
|
||||
|
||||
git_with_homebrew_token clone \
|
||||
--depth=1 \
|
||||
--no-tags \
|
||||
--single-branch \
|
||||
--branch "$default_branch" \
|
||||
"$canonical_remote" \
|
||||
"$CANARY_ROOT" >/dev/null \
|
||||
|| err "HOMEBREW_PR_TOKEN cannot clone $REPOSITORY over canonical HTTPS"
|
||||
git -C "$CANARY_ROOT" checkout -b "$CANARY_BRANCH" >/dev/null \
|
||||
|| err "could not create local Homebrew token canary branch"
|
||||
|
||||
mkdir -p "$CANARY_ROOT/.dws"
|
||||
printf 'run=%s attempt=%s actor=%s\n' "$run_id" "$run_attempt" "$actor" \
|
||||
> "$CANARY_ROOT/.dws/homebrew-token-canary.txt"
|
||||
git -C "$CANARY_ROOT" config user.name "DWS Release Bot"
|
||||
git -C "$CANARY_ROOT" config user.email "dws-release-bot@example.com"
|
||||
git -C "$CANARY_ROOT" add .dws/homebrew-token-canary.txt
|
||||
git -C "$CANARY_ROOT" commit -m "chore: verify Homebrew token [skip ci]" >/dev/null \
|
||||
|| err "could not create Homebrew token canary commit"
|
||||
CANARY_COMMIT="$(git -C "$CANARY_ROOT" rev-parse HEAD)"
|
||||
|
||||
CANARY_BRANCH_MAY_EXIST="true"
|
||||
git_with_homebrew_token -C "$CANARY_ROOT" push \
|
||||
"$canonical_remote" "HEAD:refs/heads/$CANARY_BRANCH" >/dev/null \
|
||||
|| err "HOMEBREW_PR_TOKEN cannot push a canary branch to $REPOSITORY"
|
||||
|
||||
CANARY_PR_MAY_EXIST="true"
|
||||
canary_pr_url="$(
|
||||
GH_TOKEN="$TOKEN" gh pr create \
|
||||
--repo "$REPOSITORY" \
|
||||
--base "$default_branch" \
|
||||
--head "$CANARY_BRANCH" \
|
||||
--title "chore: verify Homebrew PR token" \
|
||||
--body "Automated permission canary. This PR is closed and its branch deleted by the same workflow run." \
|
||||
--draft
|
||||
)" || err "HOMEBREW_PR_TOKEN cannot create a canary pull request in $REPOSITORY"
|
||||
CANARY_PR_NUMBER="${canary_pr_url##*/}"
|
||||
case "$CANARY_PR_NUMBER" in
|
||||
""|*[!0-9]*) err "Homebrew token canary returned an invalid pull request URL" ;;
|
||||
esac
|
||||
|
||||
close_canary_pr "$CANARY_PR_NUMBER" \
|
||||
|| err "HOMEBREW_PR_TOKEN could not close canary PR #$CANARY_PR_NUMBER"
|
||||
CANARY_PR_MAY_EXIST="false"
|
||||
CANARY_PR_NUMBER=""
|
||||
|
||||
delete_canary_branch >/dev/null \
|
||||
|| err "HOMEBREW_PR_TOKEN could not delete canary branch $CANARY_BRANCH"
|
||||
CANARY_BRANCH_MAY_EXIST="false"
|
||||
fi
|
||||
|
||||
printf 'Homebrew PR token capability passed for %s as %s (%s)\n' \
|
||||
"$REPOSITORY" "$actor" "$credential_kind"
|
||||
@@ -678,10 +678,13 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
|
||||
"governance_preflight_nonce:",
|
||||
`format('Release governance preflight {0}', inputs.governance_preflight_nonce)`,
|
||||
"name: Release governance preflight",
|
||||
"name: Check out trusted preflight tooling",
|
||||
"github.event_name == 'workflow_dispatch'",
|
||||
"EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli",
|
||||
`DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}`,
|
||||
`test "$PREFLIGHT_COMMIT" = "$GITHUB_SHA"`,
|
||||
`ref: ${{ inputs.governance_preflight_commit }}`,
|
||||
"persist-credentials: false",
|
||||
"governance preflight cannot be combined with npm repair",
|
||||
} {
|
||||
if !strings.Contains(workflow, required) {
|
||||
@@ -689,7 +692,6 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"actions/checkout",
|
||||
"contents: write",
|
||||
"goreleaser",
|
||||
"gh release",
|
||||
@@ -701,6 +703,11 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
|
||||
t.Errorf("governance preflight must not contain publishing behavior %q", forbidden)
|
||||
}
|
||||
}
|
||||
ciGate := strings.Index(preflight, "Require successful CI Gate on the preflight commit")
|
||||
homebrewCanary := strings.Index(preflight, "Verify Homebrew PR automation permission")
|
||||
if ciGate == -1 || homebrewCanary == -1 || ciGate > homebrewCanary {
|
||||
t.Error("governance preflight must validate the exact CI Gate before exposing Homebrew credentials")
|
||||
}
|
||||
|
||||
mirror := releaseWorkflowSection(t, workflow, " mirror-gitee-release:\n", "\n repair-npm:\n")
|
||||
if !strings.Contains(mirror, "needs: [release-contract, release, publish-channels]") {
|
||||
@@ -1014,14 +1021,25 @@ func TestReleaseWorkflowOpensHomebrewPROnlyForOfficialStableTags(t *testing.T) {
|
||||
t.Fatal("the built-in GITHUB_TOKEN must not receive pull-request write permission")
|
||||
}
|
||||
for _, required := range []string{
|
||||
"Check Homebrew PR automation token",
|
||||
"Verify Homebrew PR automation permission",
|
||||
"secrets.HOMEBREW_PR_TOKEN",
|
||||
"HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases",
|
||||
"verify-homebrew-pr-token.sh",
|
||||
"--canary",
|
||||
"HOMEBREW_PR_TOKEN and RELEASE_GOVERNANCE_TOKEN must use separate least-privilege identities",
|
||||
} {
|
||||
if !strings.Contains(workflow, required) {
|
||||
t.Errorf("release workflow is missing Homebrew PR token preflight %q", required)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(workflow, "Verify Homebrew PR automation permission"); got != 2 {
|
||||
t.Errorf("Homebrew PR permission preflight count = %d, want one default-branch preflight and one tag contract", got)
|
||||
}
|
||||
tagContract := releaseWorkflowSection(t, workflow, " release-contract:\n", "\n release:\n")
|
||||
tagCI := strings.Index(tagContract, "Require successful CI Gate on the sealed commit")
|
||||
tagHomebrew := strings.Index(tagContract, "Verify Homebrew PR automation permission")
|
||||
if tagCI == -1 || tagHomebrew == -1 || tagCI > tagHomebrew {
|
||||
t.Error("tag contract must validate the sealed CI Gate before exposing Homebrew credentials")
|
||||
}
|
||||
|
||||
start := strings.Index(workflow, "- name: Open stable Homebrew formula PR")
|
||||
if start == -1 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -8,7 +9,9 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var releasePlatformAssets = []string{
|
||||
@@ -1240,6 +1243,370 @@ esac
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyHomebrewPRTokenRequiresMinimalWorkingIdentity(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
binDir := t.TempDir()
|
||||
fakeGH := filepath.Join(binDir, "gh")
|
||||
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
|
||||
set -eu
|
||||
test "${GH_TOKEN:-}" = valid-token
|
||||
command="$1"
|
||||
shift
|
||||
test "$command" = api
|
||||
endpoint=""
|
||||
for argument in "$@"; do
|
||||
case "$argument" in
|
||||
repos/*|user) endpoint="$argument" ;;
|
||||
esac
|
||||
done
|
||||
case "$endpoint" in
|
||||
repos/owner/repo)
|
||||
printf 'HTTP/2.0 200 OK\nX-OAuth-Scopes: %s\n\nowner/repo\t%s\tmain\n' \
|
||||
"${GH_SCOPES-public_repo}" "${GH_CAN_PUSH:-true}"
|
||||
;;
|
||||
repos/owner/repo/pulls?state=open\&per_page=1) ;;
|
||||
user) printf '%s\n' release-bot ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
`), 0o755)
|
||||
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "verify-homebrew-pr-token.sh")
|
||||
run := func(token, scopes, canPush string) (string, error) {
|
||||
cmd := exec.Command("sh", script, "owner/repo")
|
||||
cmd.Env = []string{
|
||||
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||
"HOME=" + t.TempDir(),
|
||||
"HOMEBREW_PR_TOKEN=" + token,
|
||||
"GH_SCOPES=" + scopes,
|
||||
"GH_CAN_PUSH=" + canPush,
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
output, err := run("valid-token", "public_repo", "true")
|
||||
if err != nil || !strings.Contains(output, "Homebrew PR token capability passed for owner/repo as release-bot (classic-public_repo)") {
|
||||
t.Fatalf("valid Homebrew PR token rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
|
||||
output, err = run("valid-token", "repo", "true")
|
||||
if err == nil || !strings.Contains(output, "must have only public_repo scope") {
|
||||
t.Fatalf("overprivileged Homebrew PR token accepted: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
|
||||
output, err = run("valid-token", "", "true")
|
||||
if err != nil || !strings.Contains(output, "(fine-grained)") {
|
||||
t.Fatalf("fine-grained Homebrew PR token rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
|
||||
output, err = run("valid-token", "public_repo", "false")
|
||||
if err == nil || !strings.Contains(output, "does not have push permission") {
|
||||
t.Fatalf("non-pushing Homebrew identity accepted: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
|
||||
output, err = run("", "public_repo", "true")
|
||||
if err == nil || !strings.Contains(output, "HOMEBREW_PR_TOKEN is required") {
|
||||
t.Fatalf("missing Homebrew token accepted: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyHomebrewPRTokenCanaryCreatesAndCleans(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
r := newReleaseTestRepo(t)
|
||||
binDir := t.TempDir()
|
||||
stateDir := t.TempDir()
|
||||
realGit, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Fatalf("LookPath(git) error = %v", err)
|
||||
}
|
||||
fakeGit := filepath.Join(binDir, "git")
|
||||
mustWriteFile(t, fakeGit, []byte(`#!/bin/sh
|
||||
set -eu
|
||||
network=false
|
||||
for argument in "$@"; do
|
||||
case "$argument" in
|
||||
clone|push|ls-remote) network=true ;;
|
||||
esac
|
||||
done
|
||||
if [ "$network" = true ]; then
|
||||
printf '%s\n' "$*" >> "$GH_STATE/git-network-args"
|
||||
case " $* " in
|
||||
*" https://github.com/owner/repo.git "*) ;;
|
||||
*) exit 91 ;;
|
||||
esac
|
||||
test "${GIT_CONFIG_NOSYSTEM:-}" = 1
|
||||
test "${GIT_CONFIG_GLOBAL:-}" = /dev/null
|
||||
test "${GIT_CONFIG_COUNT:-}" = 0
|
||||
test -x "${GIT_ASKPASS:-}"
|
||||
test "$("$GIT_ASKPASS" Username)" = x-access-token
|
||||
test "$("$GIT_ASKPASS" Password)" = valid-token
|
||||
case " $* " in
|
||||
*" HEAD:refs/heads/"*)
|
||||
if [ "${BLOCK_PUSH:-false}" = true ]; then
|
||||
"$REAL_GIT" \
|
||||
-c "url.$TEST_REMOTE.insteadOf=https://github.com/owner/repo.git" \
|
||||
"$@"
|
||||
: > "$GH_STATE/push-complete-${GITHUB_RUN_ID:-unknown}"
|
||||
sleep 2
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
case " $* " in
|
||||
*" :refs/heads/"*)
|
||||
if [ "${FAIL_DELETE:-false}" = true ]; then
|
||||
exit 92
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
exec "$REAL_GIT" \
|
||||
-c "url.$TEST_REMOTE.insteadOf=https://github.com/owner/repo.git" \
|
||||
"$@"
|
||||
fi
|
||||
exec "$REAL_GIT" "$@"
|
||||
`), 0o755)
|
||||
fakeGH := filepath.Join(binDir, "gh")
|
||||
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
|
||||
set -eu
|
||||
test "${GH_TOKEN:-}" = valid-token
|
||||
command="$1"
|
||||
shift
|
||||
case "$command" in
|
||||
api)
|
||||
endpoint=""
|
||||
for argument in "$@"; do
|
||||
case "$argument" in
|
||||
repos/*|user) endpoint="$argument" ;;
|
||||
esac
|
||||
done
|
||||
case "$endpoint" in
|
||||
repos/owner/repo)
|
||||
printf 'HTTP/2.0 200 OK\nX-OAuth-Scopes: public_repo\n\nowner/repo\ttrue\tmain\n'
|
||||
;;
|
||||
repos/owner/repo/pulls?state=open\&per_page=1) ;;
|
||||
repos/owner/repo/pulls?state=open\&head=owner:*\&base=main\&per_page=2)
|
||||
if [ -f "$GH_STATE/pr-open-${GITHUB_RUN_ID:-unknown}" ]; then
|
||||
printf '%s\n' 42
|
||||
fi
|
||||
;;
|
||||
repos/owner/repo/pulls/42)
|
||||
if [ "${FAIL_CLOSE:-false}" = true ]; then
|
||||
exit 93
|
||||
fi
|
||||
rm -f "$GH_STATE/pr-open-${GITHUB_RUN_ID:-unknown}"
|
||||
printf '%s\n' closed > "$GH_STATE/pr-closed-${GITHUB_RUN_ID:-unknown}"
|
||||
;;
|
||||
user) printf '%s\n' release-bot ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
pr)
|
||||
test "$1" = create
|
||||
shift
|
||||
printf '%s\n' "$@" > "$GH_STATE/pr-args"
|
||||
: > "$GH_STATE/pr-create-started-${GITHUB_RUN_ID:-unknown}"
|
||||
if [ "${FAIL_PR_CREATE:-false}" = true ]; then
|
||||
exit 94
|
||||
fi
|
||||
: > "$GH_STATE/pr-open-${GITHUB_RUN_ID:-unknown}"
|
||||
if [ "${BLOCK_PR_CREATE:-false}" = true ]; then
|
||||
sleep 2
|
||||
fi
|
||||
printf '%s\n' 'https://github.com/owner/repo/pull/42'
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
`), 0o755)
|
||||
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "verify-homebrew-pr-token.sh")
|
||||
baseEnv := []string{
|
||||
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||
"HOME=" + t.TempDir(),
|
||||
"GH_STATE=" + stateDir,
|
||||
"HOMEBREW_PR_TOKEN=valid-token",
|
||||
"GITHUB_RUN_ATTEMPT=2",
|
||||
"REAL_GIT=" + realGit,
|
||||
"TEST_REMOTE=" + r.remote,
|
||||
}
|
||||
runCanary := func(runID string, extraEnv ...string) ([]byte, error) {
|
||||
cmd := exec.Command("sh", script, "owner/repo", "--canary")
|
||||
cmd.Dir = r.root
|
||||
cmd.Env = append(append([]string{}, baseEnv...), "GITHUB_RUN_ID="+runID)
|
||||
cmd.Env = append(cmd.Env, extraEnv...)
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
remoteHasBranch := func(branch string) bool {
|
||||
cmd := exec.Command(
|
||||
"git", "--git-dir", r.remote, "show-ref", "--verify",
|
||||
"refs/heads/"+branch,
|
||||
)
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
output, err := runCanary("12345")
|
||||
if err != nil || !strings.Contains(string(output), "Homebrew PR token capability passed") {
|
||||
t.Fatalf("Homebrew token canary error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
|
||||
prArgs, err := os.ReadFile(filepath.Join(stateDir, "pr-args"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(PR args) error = %v", err)
|
||||
}
|
||||
for _, required := range []string{
|
||||
"--draft",
|
||||
"--base\nmain",
|
||||
"--head\nautomation/homebrew-token-canary-12345-2",
|
||||
} {
|
||||
if !strings.Contains(string(prArgs), required) {
|
||||
t.Errorf("Homebrew token canary PR args are missing %q:\n%s", required, prArgs)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(stateDir, "pr-closed-12345")); err != nil {
|
||||
t.Fatalf("Homebrew token canary did not close its PR: %v", err)
|
||||
}
|
||||
gitNetworkArgs, err := os.ReadFile(filepath.Join(stateDir, "git-network-args"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(git network args) error = %v", err)
|
||||
}
|
||||
for _, required := range []string{
|
||||
"clone",
|
||||
"https://github.com/owner/repo.git",
|
||||
"HEAD:refs/heads/automation/homebrew-token-canary-12345-2",
|
||||
"--force-with-lease=refs/heads/automation/homebrew-token-canary-12345-2:",
|
||||
":refs/heads/automation/homebrew-token-canary-12345-2",
|
||||
} {
|
||||
if !strings.Contains(string(gitNetworkArgs), required) {
|
||||
t.Errorf("Homebrew token canary Git operations are missing %q:\n%s", required, gitNetworkArgs)
|
||||
}
|
||||
}
|
||||
|
||||
if remoteHasBranch("automation/homebrew-token-canary-12345-2") {
|
||||
t.Fatal("Homebrew token canary left its remote branch behind")
|
||||
}
|
||||
|
||||
output, err = runCanary("20001", "FAIL_PR_CREATE=true")
|
||||
if err == nil || !strings.Contains(string(output), "cannot create a canary pull request") {
|
||||
t.Fatalf("PR creation failure was not fail-closed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if remoteHasBranch("automation/homebrew-token-canary-20001-2") {
|
||||
t.Fatal("PR creation failure left its canary branch behind")
|
||||
}
|
||||
|
||||
output, err = runCanary("20002", "FAIL_CLOSE=true")
|
||||
if err == nil ||
|
||||
!strings.Contains(string(output), "could not close canary PR") ||
|
||||
!strings.Contains(string(output), "could not clean up Homebrew token canary PR") {
|
||||
t.Fatalf("PR cleanup failure was not fail-closed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if remoteHasBranch("automation/homebrew-token-canary-20002-2") {
|
||||
t.Fatal("PR cleanup failure left its canary branch behind")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(stateDir, "pr-open-20002")); err != nil {
|
||||
t.Fatalf("PR cleanup failure did not leave an observable open canary PR: %v", err)
|
||||
}
|
||||
|
||||
output, err = runCanary("20003", "FAIL_DELETE=true")
|
||||
if err == nil ||
|
||||
!strings.Contains(string(output), "could not delete canary branch") ||
|
||||
!strings.Contains(string(output), "could not clean up Homebrew token canary branch") {
|
||||
t.Fatalf("branch cleanup failure was not fail-closed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !remoteHasBranch("automation/homebrew-token-canary-20003-2") {
|
||||
t.Fatal("branch deletion failure was hidden instead of leaving an observable failed canary")
|
||||
}
|
||||
|
||||
termCmd := exec.Command("sh", script, "owner/repo", "--canary")
|
||||
termCmd.Dir = r.root
|
||||
termCmd.Env = append(append([]string{}, baseEnv...),
|
||||
"GITHUB_RUN_ID=20004",
|
||||
"BLOCK_PR_CREATE=true",
|
||||
)
|
||||
var termStdout bytes.Buffer
|
||||
var termStderr bytes.Buffer
|
||||
termCmd.Stdout = &termStdout
|
||||
termCmd.Stderr = &termStderr
|
||||
if err := termCmd.Start(); err != nil {
|
||||
t.Fatalf("Start(TERM canary) error = %v", err)
|
||||
}
|
||||
marker := filepath.Join(stateDir, "pr-create-started-20004")
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
_ = termCmd.Process.Kill()
|
||||
_ = termCmd.Wait()
|
||||
t.Fatalf("TERM canary did not reach PR creation:\n%s%s", termStdout.String(), termStderr.String())
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if err := termCmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("Signal(TERM canary) error = %v", err)
|
||||
}
|
||||
err = termCmd.Wait()
|
||||
exitErr, ok := err.(*exec.ExitError)
|
||||
if !ok || exitErr.ExitCode() != 143 {
|
||||
t.Fatalf("TERM canary exit = %v, want 143\nstdout:\n%s\nstderr:\n%s",
|
||||
err, termStdout.String(), termStderr.String())
|
||||
}
|
||||
if remoteHasBranch("automation/homebrew-token-canary-20004-2") {
|
||||
t.Fatal("TERM canary left its remote branch behind")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(stateDir, "pr-closed-20004")); err != nil {
|
||||
t.Fatalf("TERM canary did not close the PR created during the signal race: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(stateDir, "pr-open-20004")); !os.IsNotExist(err) {
|
||||
t.Fatalf("TERM canary left an open PR marker: %v", err)
|
||||
}
|
||||
|
||||
pushTermCmd := exec.Command("sh", script, "owner/repo", "--canary")
|
||||
pushTermCmd.Dir = r.root
|
||||
pushTermCmd.Env = append(append([]string{}, baseEnv...),
|
||||
"GITHUB_RUN_ID=20005",
|
||||
"BLOCK_PUSH=true",
|
||||
)
|
||||
var pushTermStdout bytes.Buffer
|
||||
var pushTermStderr bytes.Buffer
|
||||
pushTermCmd.Stdout = &pushTermStdout
|
||||
pushTermCmd.Stderr = &pushTermStderr
|
||||
if err := pushTermCmd.Start(); err != nil {
|
||||
t.Fatalf("Start(push TERM canary) error = %v", err)
|
||||
}
|
||||
pushMarker := filepath.Join(stateDir, "push-complete-20005")
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(pushMarker); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
_ = pushTermCmd.Process.Kill()
|
||||
_ = pushTermCmd.Wait()
|
||||
t.Fatalf("push TERM canary did not complete its remote push:\n%s%s",
|
||||
pushTermStdout.String(), pushTermStderr.String())
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if err := pushTermCmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("Signal(push TERM canary) error = %v", err)
|
||||
}
|
||||
err = pushTermCmd.Wait()
|
||||
exitErr, ok = err.(*exec.ExitError)
|
||||
if !ok || exitErr.ExitCode() != 143 {
|
||||
t.Fatalf("push TERM canary exit = %v, want 143\nstdout:\n%s\nstderr:\n%s",
|
||||
err, pushTermStdout.String(), pushTermStderr.String())
|
||||
}
|
||||
if remoteHasBranch("automation/homebrew-token-canary-20005-2") {
|
||||
t.Fatal("push TERM canary left its remote branch behind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandCleansLocalTagWhenPushFails(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
|
||||
Reference in New Issue
Block a user