Repository navigation
feat(#613): relocate canary-rollout engine + bootstrap into .github (workflow disabled) #624
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
b720f88
feat(#613): relocate canary-rollout engine + bootstrap into .github (…
don-petry 03ccf84
chore: dev-lead update (review-changes) [skip ci-relay]
donpetry-bot 6491f3f
fix(#613): address advisory-bot findings (Gemini/Copilot/CodeRabbit)
don-petry 2e0df70
Merge branch 'main' into epic-613-relocate-canary-engine
don-petry 685f4b5
Merge branch 'main' into epic-613-relocate-canary-engine
don-petry File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,260 @@ | ||
| # ───────────────────────────────────────────────────────────────────────────── | ||
| # Canary Rollout — ring-staged, health-gated promotion of agent releases by moving | ||
| # channel tags only. Initiative #495 · issues #501 (promotion) / #502 (rollback). | ||
| # Gate standard: .github#548 (graduated dwell/sample, robust baseline, per-candidate | ||
| # window, ring0 sample waiver, failure triage) — see scripts/canary-rollout.sh. | ||
| # | ||
| # The ONLY mutation this performs is moving a channel tag via a `gh api` ref update | ||
| # (PATCH/POST repos/<host>/git/refs/tags/… — NOT a local `git push`, which the | ||
| # release-channel-tags ruleset would reject) and recording a GitHub Deployment for | ||
| # traceability. It never writes consumer files. | ||
| # | ||
| # Auth: the mover credential is a GitHub App installation token minted at runtime via | ||
| # actions/create-github-app-token from RELEASE_MANAGER_APP_CLIENT_ID (variable) + RELEASE_MANAGER_APP_KEY (secret) (scoped, | ||
| # rotatable, auditable) — NOT a raw PAT and NOT github.token. The App carries | ||
| # Contents:write (tag moves on the host repos), Deployments:write, Issues:write (the | ||
| # per-agent blocker issues in sync-issues; the fleet-status table goes to the job summary, | ||
| # not an issue), Actions:read (the gate reads workflow-run history across every tier repo) | ||
| # and Metadata:read. Minting | ||
| # is a HARD requirement: if the token cannot be minted the job fails — it must not | ||
| # silently degrade to github.token, which cannot move protected `.github` tags (#868). | ||
| # | ||
| # - schedule (every 4h): the ordering is autocut → promote-all → sync-issues. | ||
| # FIRST `autocut` (#1069) — the FRONT END that closes the last manual seam: gated by the | ||
| # org variable CANARY_AUTO_CUT (the single kill-switch), it cuts a new <agent>/vX.Y.Z + | ||
| # moves `next` whenever a registered reusable's blob on its host main HEAD differs from the | ||
| # current `next` candidate, seeding the soak/promote pipeline with no manual cut-release.sh. | ||
| # A freshly cut candidate SOAKS the same tick (dwell=0 < floor). It is best-effort — a | ||
| # failure here never fails the run. THEN the gated fleet auto-promote (#1045 part b). When the org | ||
| # variable CANARY_AUTO_PROMOTE == 'true' the timer runs `promote-all` — the gate | ||
| # advances each PROMOTE-ready agent one ring per run and leaves BLOCKED/REGRESSION | ||
| # agents untouched (human approval is OVERRIDE-ONLY, never a required gate). When the | ||
| # variable is unset/false it runs read-only `evaluate-all` — evaluates EVERY agent in | ||
| # canary-rings.json (fleet-wide, #1025 P1) and emits each one's per-version gate + | ||
| # health report (#502 observability). The arm variable is the single kill-switch. | ||
| # After promoting, the timer also runs `sync-issues`: it upserts one tracked issue per | ||
| # BLOCKED agent (failing-run evidence attached; REGRESSION → needs-human; auto-closed on | ||
| # clear) and renders the whole-fleet status table into the run's job summary (auto-triage). | ||
| # - workflow_dispatch: `evaluate` | `evaluate-all` | `promote` | `promote-all` | | ||
| # `rollback` | `sync-issues`, human-gated (promote/promote-all/sync-issues default to --dry-run). | ||
| # ───────────────────────────────────────────────────────────────────────────── | ||
| name: Canary Rollout | ||
|
|
||
| on: | ||
| # schedule: # DISABLED until epic #613 step-4 cutover — see PR (relocated from .github-private, must not double-run) | ||
| # - cron: "0 */4 * * *" # every 4 hours — evaluate only (RE-ENABLE at cutover) | ||
| workflow_dispatch: | ||
| inputs: | ||
| command: | ||
| description: "autocut (gated cut+seed) | evaluate | evaluate-all (read-only) | promote | promote-all (gated tag move) | rollback | sync-issues | drift (read-only registry audit)" | ||
| type: choice | ||
| options: [autocut, evaluate, evaluate-all, promote, promote-all, rollback, sync-issues, drift] | ||
| default: evaluate | ||
| agent: | ||
| description: "agent to act on" | ||
| type: choice | ||
| options: [dev-lead, agent-shield, auto-rebase, dependency-audit, dependabot-automerge, dependabot-rebase, pr-review-mention] | ||
| default: dev-lead | ||
| override: | ||
| description: "promote: advance even if the gate is not PROMOTE (records a warning)" | ||
| type: boolean | ||
| default: false | ||
| dry_run: | ||
| description: "promote/rollback: show the tag move but do not push" | ||
| type: boolean | ||
| default: true | ||
| ring: | ||
| description: "rollback: which ring channel to move (e.g. ring1, stable)" | ||
| type: string | ||
| default: "" | ||
| to: | ||
| description: "rollback: target immutable release (e.g. v1.3.0)" | ||
| type: string | ||
| default: "" | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| # Scheduled runs evaluate the whole fleet (read-only), so they group under 'fleet' | ||
| # and never serialize behind a per-agent promote/rollback dispatch (#1025 P1). | ||
| group: canary-rollout-${{ github.event.inputs.agent || 'fleet' }} | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| canary: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 15 | ||
| steps: | ||
| # Mint a scoped GitHub App installation token (Contents/Deployments:write, | ||
| # Actions/Metadata:read). owner-wide so the gate can read run history across all | ||
| # tier repos and move tags on the host repos. This step FAILS the job if the App | ||
| # id/key secrets are missing or the token cannot be minted — no silent fallback. | ||
| - name: Mint GitHub App token | ||
| id: app-token | ||
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 | ||
| with: | ||
| # client-id (not the deprecated app-id, #1025 P3). This is the App's client ID, | ||
| # a distinct value from the numeric app id — set vars.RELEASE_MANAGER_APP_CLIENT_ID. | ||
| client-id: ${{ vars.RELEASE_MANAGER_APP_CLIENT_ID }} | ||
| private-key: ${{ secrets.RELEASE_MANAGER_APP_KEY }} | ||
| # owner-wide (NOT a fixed repositories: list). The fleet gate reads run history | ||
| # across every tier repo, including the stable-tier '*' member (all consumers), | ||
| # which cannot be enumerated — so scoping to a repo list would break the gate (#1025 P3). | ||
| owner: ${{ github.repository_owner }} | ||
|
|
||
| - name: Verify App token was minted (hard requirement) | ||
| env: | ||
| APP_TOKEN: ${{ steps.app-token.outputs.token }} | ||
| run: | | ||
| set -euo pipefail | ||
| if [ -z "${APP_TOKEN}" ]; then | ||
| echo "::error::Canary rollout requires a GitHub App token" \ | ||
| "(RELEASE_MANAGER_APP_CLIENT_ID (variable) + RELEASE_MANAGER_APP_KEY (secret))." \ | ||
| "Refusing to fall back to github.token, which cannot move protected .github channel tags (#868)." | ||
| exit 1 | ||
| fi | ||
|
|
||
| - name: Checkout (with tags) | ||
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| fetch-depth: 0 | ||
| fetch-tags: true | ||
| # Tag moves go through `gh api` with the App token (GH_TOKEN env), never a | ||
| # local `git push`, so the checkout credential is only needed to authenticate | ||
| # the fetch — don't persist it into .git/config (narrows the exposure surface). | ||
| persist-credentials: false | ||
| token: ${{ steps.app-token.outputs.token }} | ||
|
|
||
| # FRONT END (#1069): cut a new candidate + move `next` whenever a registered reusable's | ||
| # blob on its host main HEAD differs from the current `next` candidate. Runs FIRST on the | ||
| # scheduled tick (before promote-all) so a freshly cut candidate begins soaking the same | ||
| # tick. Gated by CANARY_AUTO_CUT (the single kill-switch) — the script no-ops unless it is | ||
| # 'true'. Best-effort: a cut/detection hiccup must never fail the scheduled promotion run. | ||
| - name: Autocut new candidates (front end) | ||
| if: github.event_name == 'schedule' | ||
| env: | ||
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | ||
| CANARY_AUTO_CUT: ${{ vars.CANARY_AUTO_CUT }} | ||
| run: | | ||
| set -uo pipefail | ||
| bash scripts/canary-rollout.sh autocut || echo "::warning::autocut failed (non-fatal)" | ||
|
|
||
| - name: Run canary-rollout | ||
| id: run | ||
| env: | ||
| # autocut kill-switch — honored when a dispatch runs `autocut` (the script no-ops unless 'true'). | ||
| CANARY_AUTO_CUT: ${{ vars.CANARY_AUTO_CUT }} | ||
| # On the 4h timer the arm variable decides: CANARY_AUTO_PROMOTE=='true' runs the | ||
| # gated fleet auto-promote (promote-all, #1045 part b); anything else runs read-only | ||
| # fleet-wide evaluate-all (#1025 P1). Either way EVERY registry agent is covered, not | ||
| # just dev-lead. The gate — not the schedule — decides which agents actually advance. | ||
| CMD: ${{ github.event_name == 'schedule' && (vars.CANARY_AUTO_PROMOTE == 'true' && 'promote-all' || 'evaluate-all') || github.event.inputs.command }} | ||
| AGENT: ${{ github.event.inputs.agent || 'dev-lead' }} | ||
| OVERRIDE: ${{ github.event.inputs.override }} | ||
| # Scheduled promote-all is a REAL move (DRY_RUN=false); dispatch honours the input | ||
| # (which defaults to true — dispatch promote/promote-all are dry unless set false). | ||
| DRY_RUN: ${{ github.event_name == 'schedule' && 'false' || github.event.inputs.dry_run }} | ||
| RING: ${{ github.event.inputs.ring }} | ||
| TO: ${{ github.event.inputs.to }} | ||
| # cut-release.sh (cross-repo tag moves on petry-projects/.github) and the | ||
| # gate's cross-repo run-history reads both authenticate with the App token. | ||
| GH_TOKEN: ${{ steps.app-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 | ||
| run: | | ||
| set -euo pipefail | ||
| args=() | ||
| case "$CMD" in | ||
| autocut) # gated cut+seed of new candidates (#1069) | ||
| args=(autocut) | ||
| [ "$DRY_RUN" != "false" ] && args+=(--dry-run) # default-safe: dry unless explicitly false | ||
| ;; | ||
| evaluate) args=(evaluate "$AGENT") ;; | ||
| evaluate-all) args=(evaluate-all) ;; # fleet-wide read-only (the 4h timer, #1025 P1) | ||
| promote) | ||
| args=(promote "$AGENT") | ||
| [ "$OVERRIDE" = "true" ] && args+=(--override) | ||
| [ "$DRY_RUN" != "false" ] && args+=(--dry-run) # default-safe: dry unless explicitly false | ||
| ;; | ||
| promote-all) # gated fleet auto-promote (the SCHEDULED arm, #1045b) | ||
| args=(promote-all) | ||
| [ "$OVERRIDE" = "true" ] && args+=(--override) | ||
| [ "$DRY_RUN" != "false" ] && args+=(--dry-run) # default-safe: dry unless explicitly false | ||
| ;; | ||
| rollback) | ||
| if [ -z "$RING" ] || [ -z "$TO" ]; then echo "::error::rollback needs ring + to"; exit 2; fi | ||
| args=(rollback "$AGENT" "$RING" --to "$TO") | ||
| [ "$DRY_RUN" != "false" ] && args+=(--dry-run) | ||
| ;; | ||
| sync-issues) # upsert blocker issues + dashboard (auto-triage) | ||
| args=(sync-issues) | ||
| [ "$DRY_RUN" != "false" ] && args+=(--dry-run) # default-safe: dry unless explicitly false | ||
| ;; | ||
| drift) args=(drift) ;; # read-only registry/host reusable audit (#1082) | ||
| *) echo "::error::unknown command: $CMD"; exit 2 ;; | ||
| esac | ||
| echo "::notice::canary-rollout ${args[*]}" | ||
| bash scripts/canary-rollout.sh "${args[@]}" | ||
|
|
||
| # Record a GitHub Deployment per real (non-dry-run) promotion — traceability for the | ||
| # tag moves (#502). Reads the promotions log so a promote-all run records EVERY move, | ||
| # each on the repo that owns the moved commit (a cross-repo agent's SHA lives on its | ||
| # host, not GITHUB_REPOSITORY — creating it there 422s "No ref found", #1059). | ||
| # always() + best-effort per line: a traceability hiccup must never mark a SUCCESSFUL | ||
| # tag move as a failed run. | ||
| - name: Record deployment for promotions | ||
| if: always() && hashFiles(format('{0}/promotions.tsv', runner.temp)) != '' | ||
| env: | ||
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | ||
| CANARY_PROMOTIONS_LOG: ${{ runner.temp }}/promotions.tsv | ||
| run: | | ||
| set -uo pipefail | ||
| while IFS=$'\t' read -r agent ring sha repo; do | ||
| [ -z "${agent:-}" ] && continue | ||
| echo "recording deployment: ${agent}/${ring} -> ${sha:0:12} on ${repo}" | ||
| gh api -X POST "repos/${repo}/deployments" --input - <<JSON || echo "::warning::deployment record failed for ${agent}/${ring} on ${repo} (tag move already succeeded)" | ||
| { | ||
| "ref": "${sha}", | ||
| "environment": "canary-${agent}-${ring}", | ||
| "auto_merge": false, | ||
| "required_contexts": [], | ||
| "transient_environment": false, | ||
| "production_environment": false, | ||
| "description": "canary-rollout promoted ${agent}/${ring} -> ${sha}" | ||
| } | ||
| JSON | ||
| done < "${CANARY_PROMOTIONS_LOG}" | ||
|
|
||
| # Auto-triage: turn the gate's BLOCKED verdicts into tracked work + a fleet readout. | ||
| # From the post-promotion fleet state: (1) upsert ONE idempotent issue per BLOCKED agent | ||
| # (failing-run evidence pre-attached; REGRESSION → needs-human; auto-closed on clear), and | ||
| # (2) render the whole-fleet status table into THIS run's job summary (a read-only | ||
| # snapshot — no dashboard issue, no Issues:write needed for it). Best-effort + always(): | ||
| # a GitHub-side hiccup must never fail the promotion run. The blocker ISSUES need the App | ||
| # to carry Issues:write — if absent that part warns and continues; the summary still renders. | ||
| - name: Sync blocker issues + fleet-status summary | ||
| if: always() && github.event_name == 'schedule' | ||
| env: | ||
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | ||
| ISSUE_REPO: ${{ github.repository }} | ||
| run: | | ||
| set -uo pipefail | ||
| bash scripts/canary-rollout.sh sync-issues || echo "::warning::issue sync failed (non-fatal)" | ||
|
|
||
| # Reusable-registry drift audit (#1082): the registry (.agents{}) is the MANUAL source | ||
| # of truth for what this pipeline manages — a *-reusable.yml added/renamed on a host but | ||
| # never registered ships with ZERO staged rollout (no cut/soak/gate/dashboard) until a | ||
| # human registers it. This read-only step scans every host's .github/workflows for | ||
| # *-reusable.yml, diffs against the registry, and surfaces (a) unregistered reusables and | ||
| # (b) stale registry entries whose file is gone — as ::warning:: annotations + a job-summary | ||
| # table, within one scheduled cycle. Report-only (never registers anything). Best-effort + | ||
| # always(): a drift-scan hiccup must never fail the promotion run. | ||
| - name: Reusable-registry drift audit | ||
| if: always() && github.event_name == 'schedule' | ||
| env: | ||
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | ||
| run: | | ||
| set -uo pipefail | ||
| bash scripts/canary-rollout.sh drift || echo "::warning::drift audit failed (non-fatal)" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.