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
35 changes: 35 additions & 0 deletions .github/workflows/canary-rollout.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,35 @@ jobs:
exit 1
fi

# Mint a SECOND, repo-scoped installation token for the tag WRITES only (#745). The
# owner-wide token above is refused the release-channel-tags ruleset bypass on a
# protected channel-tag PATCH — 403 "Resource not accessible by integration": GitHub
# evaluates an App's bypass/effective-permissions differently for an owner-level token
# than for a repo-scoped installation token. This token is scoped to just the two
# tag-host repos (every agent's registry host is .github or .github-private) with an
# explicit Contents:write, and is passed to the engine as CANARY_WRITE_TOKEN so ONLY
# _gh_move_tag / _gh_create_annotated_tag use it — the fleet gate's cross-repo
# run-history reads keep the owner-wide token (don't regress the '*' stable-tier
# enumeration). Not a hard requirement to verify separately: if the write token is
# empty the engine falls back to the owner-wide token and the move re-fails loudly.
# Confirmed by #744's un-suppressed error (run 29435711407): the owner-wide-token
# PATCH .../git/refs/tags/<channel-tag> returned 403 "Resource not accessible by
# integration" while an admin-token PATCH of the same ref succeeded.
- name: Mint repo-scoped write token (tag moves)
id: write-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.RELEASE_MANAGER_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_MANAGER_APP_KEY }}
owner: ${{ github.repository_owner }}
# Scope to exactly the tag-host repos (NOT owner-wide) and request Contents:write
# explicitly — this is the combination that carries the ruleset bypass for a
# protected channel-tag UPDATE.
repositories: |
.github
.github-private
permission-contents: write

- name: Checkout (with tags)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
Expand All @@ -142,6 +171,9 @@ jobs:
if: github.event_name == 'schedule'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
# Repo-scoped write token for the inline cut's tag writes (_gh_create_annotated_tag
# + _gh_move_tag). The owner-wide GH_TOKEN above stays the credential for reads (#745).
CANARY_WRITE_TOKEN: ${{ steps.write-token.outputs.token }}
CANARY_AUTO_CUT: ${{ vars.CANARY_AUTO_CUT }}
run: |
set -uo pipefail
Expand Down Expand Up @@ -171,6 +203,9 @@ jobs:
# The inline autocut cut (create+move tags on each agent's host) and the gate's
# cross-repo run-history reads both authenticate with the App token.
GH_TOKEN: ${{ steps.app-token.outputs.token }}
# Repo-scoped write token used ONLY for the channel-tag PATCH/POST in _gh_move_tag /
# _gh_create_annotated_tag (promote-all's real moves). Reads/gate keep GH_TOKEN (#745).
CANARY_WRITE_TOKEN: ${{ steps.write-token.outputs.token }}
# One TSV line per real promotion (agent, ring, sha, owning-repo) so the deployment
# step records a deployment for EVERY move in a promote-all run, on the right host.
CANARY_PROMOTIONS_LOG: ${{ runner.temp }}/promotions.tsv
Expand Down
26 changes: 22 additions & 4 deletions scripts/canary-rollout.sh
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,24 @@ _gh_tag_commit() {
fi
}

# _gh_write <gh-args…> — run a `gh` mutation with the repo-scoped WRITE token when one is
# provided (CANARY_WRITE_TOKEN), else the ambient GH_TOKEN. The owner-wide token the workflow
# mints for the fleet gate's cross-repo run-history reads is refused a release-channel-tags
# ruleset bypass on a protected channel-tag PATCH — 403 "Resource not accessible by
# integration" (#745): GitHub evaluates an App's bypass/effective-permissions differently for
# an owner-level token than for a repo-scoped installation token. So tag writes are routed
# through a SECOND token minted repo-scoped (repositories: .github + .github-private) with an
# explicit Contents:write, while reads keep the owner-wide token (don't regress the '*'
# stable-tier enumeration). Absent CANARY_WRITE_TOKEN (e.g. unit tests / local runs) it is a
# transparent pass-through, so every existing GH_TOKEN-only path is unchanged.
_gh_write() {
if [ -n "${CANARY_WRITE_TOKEN:-}" ]; then
GH_TOKEN="$CANARY_WRITE_TOKEN" gh "$@"
else
gh "$@"
fi
}

# _gh_move_tag <repo> <tag> <sha> — force-move (or create) the lightweight ref
# refs/tags/<tag> on <repo> to <sha> via the GitHub API. THE channel-tag mover for every
# agent (promote + rollback), this-repo and cross-repo alike (#1076): the API path is
Expand All @@ -123,7 +141,7 @@ _gh_tag_commit() {
_gh_move_tag() {
[ $# -lt 3 ] && return 1
local repo="$1" tag="$2" sha="$3" out rc
out="$(gh api -X PATCH "repos/$repo/git/refs/tags/$tag" \
out="$(_gh_write api -X PATCH "repos/$repo/git/refs/tags/$tag" \
-f sha="$sha" -F force=true 2>&1)" && return 0
rc=$?
# Only a genuinely-absent ref (404) warrants creating it; every other PATCH failure is a
Expand All @@ -133,7 +151,7 @@ _gh_move_tag() {
echo "::error::_gh_move_tag: could not move refs/tags/$tag -> ${sha:0:12} on $repo (HTTP): ${out//$'\n'/ }" >&2
return "$rc"
fi
out="$(gh api -X POST "repos/$repo/git/refs" \
out="$(_gh_write api -X POST "repos/$repo/git/refs" \
-f ref="refs/tags/$tag" -f sha="$sha" 2>&1)" && return 0
rc=$?
echo "::error::_gh_move_tag: could not create refs/tags/$tag -> ${sha:0:12} on $repo (HTTP): ${out//$'\n'/ }" >&2
Expand All @@ -152,14 +170,14 @@ _gh_move_tag() {
_gh_create_annotated_tag() {
[ $# -lt 4 ] && return 1
local repo="$1" tag="$2" sha="$3" message="$4" obj
obj="$(gh api -X POST "repos/$repo/git/tags" \
obj="$(_gh_write api -X POST "repos/$repo/git/tags" \
-f tag="$tag" -f message="$message" -f object="$sha" -f type=commit \
--jq '.sha // empty')"
if [ $? -ne 0 ] || [ -z "$obj" ]; then
echo "::error::_gh_create_annotated_tag: could not create the annotated release tag on $repo or read back its object SHA" >&2
return 1
fi
gh api -X POST "repos/$repo/git/refs" \
_gh_write api -X POST "repos/$repo/git/refs" \
-f ref="refs/tags/$tag" -f sha="$obj" >/dev/null 2>&1 || {
echo "::error::_gh_create_annotated_tag: created tag object $obj on $repo but could not publish ref refs/tags/$tag" >&2
return 1
Expand Down
48 changes: 48 additions & 0 deletions tests/canary_rollout.bats
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,54 @@ GHEOF
[[ "$output" == *"Validation Failed"* ]]
}

# ── #745: protected channel-tag WRITES go through a repo-scoped write token ──────
# The owner-wide App token is refused a release-channel-tags ruleset bypass on a PATCH
# of a protected channel tag (403 "Resource not accessible by integration") — GitHub
# evaluates an App's bypass/effective-perms differently for an owner-level token than a
# repo-scoped installation token. The fix mints a SECOND, repo-scoped token
# (Contents:write on the tag hosts) and routes ONLY the tag writes through it via
# CANARY_WRITE_TOKEN — the read-only fleet gate keeps the owner-wide GH_TOKEN so the
# '*' stable-tier enumeration is not regressed. Each stub line records which token was
# in effect (the GH_TOKEN visible to the `gh` child) for the mutation.
_token_capture_stub() {
STUB_BIN="$(mktemp -d "$BATS_TEST_TMPDIR/stub.XXXXXX")"; export PATH="$STUB_BIN:$PATH"
export TOKEN_LOG="$STUB_BIN/tokens.log"; : > "$TOKEN_LOG"
cat > "$STUB_BIN/gh" <<'GHEOF'
#!/usr/bin/env bash
printf '%s\t%s\n' "$*" "${GH_TOKEN:-<unset>}" >> "$TOKEN_LOG"
echo "1111111111111111111111111111111111111111"
exit 0
GHEOF
chmod +x "$STUB_BIN/gh"
}

@test "_gh_move_tag: routes the PATCH through CANARY_WRITE_TOKEN when set (#745)" {
_token_capture_stub
run env GH_TOKEN=owner-wide CANARY_WRITE_TOKEN=repo-scoped bash -c \
"source '$ORCH' && _gh_move_tag petry-projects/.github agent-shield/v2-ring0 12b0075a9c48000000000000000000000000000"
[ "$status" -eq 0 ]
grep -q 'PATCH.*repo-scoped' "$TOKEN_LOG"
! grep -q 'PATCH.*owner-wide' "$TOKEN_LOG"
}

@test "_gh_move_tag: falls back to the ambient GH_TOKEN when CANARY_WRITE_TOKEN is unset (#745)" {
_token_capture_stub
run env -u CANARY_WRITE_TOKEN GH_TOKEN=owner-wide bash -c \
"source '$ORCH' && _gh_move_tag petry-projects/.github agent-shield/v2-ring0 12b0075a9c48000000000000000000000000000"
[ "$status" -eq 0 ]
grep -q 'PATCH.*owner-wide' "$TOKEN_LOG"
}

@test "_gh_create_annotated_tag: routes the object create + ref publish through CANARY_WRITE_TOKEN when set (#745)" {
_token_capture_stub
run env GH_TOKEN=owner-wide CANARY_WRITE_TOKEN=repo-scoped bash -c \
"source '$ORCH' && _gh_create_annotated_tag petry-projects/.github agent-shield/v2.0.0 12b0075a9c48000000000000000000000000000 'agent-shield release v2.0.0'"
[ "$status" -eq 0 ]
# both the git/tags object create and the git/refs publish must carry the write token
[ "$(grep -c 'repo-scoped' "$TOKEN_LOG")" -ge 2 ]
! grep -q 'owner-wide' "$TOKEN_LOG"
}

# ── orchestrator: sync-issues — auto-triage held promotions into tracked issues ──
# A held (BLOCKED) promotion files/updates ONE idempotent issue per agent with the failing-run
# evidence; a cleared agent's issue auto-closes; the fleet-status table is rendered to the job
Expand Down
Loading