Repository navigation
fix(sync): enforce canary promotion boundary #3039
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
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,7 +25,7 @@ | |
| workflow_dispatch: | ||
| inputs: | ||
| repos: | ||
| description: 'Comma-separated repos to sync (empty for all registered)' | ||
| description: 'Optional repo subset (canary phase accepts configured canaries only)' | ||
| type: string | ||
| required: false | ||
| dry_run: | ||
|
|
@@ -83,6 +83,7 @@ | |
| template_hash: ${{ steps.manifest.outputs.template_hash }} | ||
| plan_id: ${{ steps.manifest.outputs.plan_id }} | ||
| phase: ${{ steps.repos.outputs.phase }} | ||
| sync_branch: ${{ steps.repos.outputs.sync_branch }} | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v7 | ||
|
|
@@ -149,7 +150,16 @@ | |
| --output sync-phase-selection.json | ||
| echo "matrix=$(jq -c '.matrix' sync-phase-selection.json)" >> "$GITHUB_OUTPUT" | ||
| echo "phase=$phase" >> "$GITHUB_OUTPUT" | ||
| if [ "$phase" = "canary" ]; then | ||
| # Candidate corrections update the same bounded PR in each canary | ||
| # repository. Only a plan-bound promotion gets immutable hash branches. | ||
| sync_branch="sync/workflows-candidate" | ||
| else | ||
| sync_branch="sync/workflows-${{ steps.manifest.outputs.template_hash }}" | ||
| fi | ||
| echo "sync_branch=$sync_branch" >> "$GITHUB_OUTPUT" | ||
| echo "Selected phase: $phase" | ||
| echo "Sync branch: $sync_branch" | ||
| jq -r '.selected_repos[]' sync-phase-selection.json | ||
|
|
||
| - name: Upload plan and prospective-diff evidence | ||
|
|
@@ -717,7 +727,7 @@ | |
| github-token: ${{ env.REPO_TOKEN }} | ||
| script: | | ||
| const { isConsumerOpenPr } = require('./workflows/.github/scripts/sync_tracker_state'); | ||
| const branchName = 'sync/workflows-${{ needs.prepare.outputs.template_hash }}'; | ||
| const branchName = '${{ needs.prepare.outputs.sync_branch }}'; | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| const exists = await isConsumerOpenPr({ | ||
| github, | ||
|
|
@@ -740,7 +750,7 @@ | |
| run: | | ||
| cd consumer | ||
|
|
||
| branch_name="sync/workflows-${{ needs.prepare.outputs.template_hash }}" | ||
| branch_name="${{ needs.prepare.outputs.sync_branch }}" | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
|
|
||
| # Configure git for push/fetch authentication using credential helper | ||
| # This avoids exposing token in git remote URL or command output. | ||
|
|
@@ -752,8 +762,10 @@ | |
| git config credential.helper "$credential_helper" | ||
|
|
||
| # Keep an existing generated PR current instead of treating it as a | ||
| # terminal delivery attempt: rebuild its branch and refresh its lease | ||
| # only when the existing attempt still has a current delivery record. | ||
| # terminal delivery attempt. Immutable promotion branches refresh only | ||
| # within one generation. The stable canary branch may rotate to a new | ||
| # candidate plan when its existing delivery record is valid and owned | ||
| # by this consumer repository. | ||
| existing_pr=$(gh pr list --head "$branch_name" --json number -q '.[0].number' || echo "") | ||
| existing_head="" | ||
| existing_base="" | ||
|
|
@@ -776,6 +788,8 @@ | |
| DELIVERY_GENERATION="$DELIVERY_GENERATION" \ | ||
| PLAN_ID="$PLAN_ID" \ | ||
| DELIVERY_REPOSITORY="$DELIVERY_REPOSITORY" \ | ||
| SYNC_PHASE="$SYNC_PHASE" \ | ||
| SYNC_BRANCH="$branch_name" \ | ||
| node -e ' | ||
| const fs = require("fs"); | ||
| const { parseDeliveryRecord, mergeEligibility } = require("../workflows/.github/scripts/sync_pr_lease_contract"); | ||
|
|
@@ -785,6 +799,21 @@ | |
| process.stdout.write("false missing_or_invalid"); | ||
| process.exit(0); | ||
| } | ||
| const isStableCandidate = | ||
| process.env.SYNC_PHASE === "canary" && | ||
| process.env.SYNC_BRANCH === "sync/workflows-candidate"; | ||
| if (isStableCandidate) { | ||
| if (record.repository !== (process.env.DELIVERY_REPOSITORY || "")) { | ||
| process.stdout.write("false repository_mismatch"); | ||
| process.exit(0); | ||
| } | ||
| if (record.terminal_disposition) { | ||
| process.stdout.write(`false terminal:${record.terminal_disposition}`); | ||
| process.exit(0); | ||
| } | ||
| process.stdout.write("true candidate_plan_rotation"); | ||
| process.exit(0); | ||
| } | ||
|
Comment on lines
+806
to
+820
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -C 6 \
'gh pr list --head|isConsumerOpenPr|head\.repo|head_repo|full_name|candidate_plan_rotation' \
.github/workflows/maint-68-sync-consumer-repos.yml .github/scriptsRepository: stranske/Workflows Length of output: 50374 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- workflow lookup and refresh path ---'
sed -n '710,855p' .github/workflows/maint-68-sync-consumer-repos.yml
printf '%s\n' '--- isConsumerOpenPr implementation ---'
sed -n '390,455p' .github/scripts/sync_tracker_state/index.js
printf '%s\n' '--- all workflow copies ---'
find .github/workflows templates/consumer-repo/.github/workflows -maxdepth 1 -type f \
\( -name 'maint-68-sync-consumer-repos.yml' -o -name 'maint-68-sync-consumer-repos.yaml' \) -printRepository: stranske/Workflows Length of output: 9627 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- workflow setup and refresh execution ---'
rg -n -C 8 \
'actions/checkout|cd consumer|gh pr list|gh pr view|gh pr edit|existing_refreshable|existing_pr|git fetch origin' \
.github/workflows/maint-68-sync-consumer-repos.yml
printf '%s\n' '--- template workflow inventory ---'
find templates -type f 2>/dev/null | head -80 || true
rg -n 'maint-68-sync-consumer-repos|sync/workflows-candidate' templates .github/workflows 2>/dev/null || true
printf '%s\n' '--- representative acceptance-path verifier ---'
python3 - <<'PY'
import re
workflow = open('.github/workflows/maint-68-sync-consumer-repos.yml', encoding='utf-8').read()
helper = open('.github/scripts/sync_tracker_state/index.js', encoding='utf-8').read()
lookup = re.search(
r'existing_pr=\$\(gh pr list --head "\$branch_name" --json number -q \'\.\[0\]\.number\'',
workflow,
)
view_body_only = 'gh pr view "$existing_pr" --json body -q .body' in workflow
fetch_origin_branch = 'git fetch origin "$branch_name"' in workflow
helper_uses_head_repo = bool(re.search(r'head\?\.repo|head\.repo|headRepo|full_name', helper[helper.index('async function isConsumerOpenPr'):helper.index('function formatStuckWindowMarker')]))
helper_returns_boolean = 'return pulls.some' in helper[helper.index('async function isConsumerOpenPr'):helper.index('function formatStuckWindowMarker')]
def candidate_accepts(record, delivery_repo):
return (
record.get('repository') == delivery_repo
and not record.get('terminal_disposition')
)
cases = [
('consumer PR', {'repository': 'owner/consumer', 'terminal_disposition': ''}),
('fork PR with forged body', {'repository': 'owner/consumer', 'terminal_disposition': ''}),
]
print({
'lookup_requests_only_number': bool(lookup),
'body_query_omits_head_repo': view_body_only,
'origin_fetches_branch_before_refresh': fetch_origin_branch,
'isConsumerOpenPr_reads_head_repo': helper_uses_head_repo,
'isConsumerOpenPr_returns_only_match_boolean': helper_returns_boolean,
'candidate_body_acceptance': {
name: candidate_accepts(record, 'owner/consumer')
for name, record in cases
},
})
PYRepository: stranske/Workflows Length of output: 263 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- workflow setup and refresh execution ---'
rg -n -C 8 'actions/checkout|cd consumer|gh pr list|gh pr view|gh pr edit|existing_refreshable|existing_pr|git fetch origin' \
.github/workflows/maint-68-sync-consumer-repos.yml || true
printf '%s\n' '--- template workflow inventory ---'
if [ -d templates ]; then
find templates -type f | head -80
else
echo 'templates directory is absent'
fi
rg -n 'maint-68-sync-consumer-repos|sync/workflows-candidate' templates .github/workflows 2>/dev/null || true
printf '%s\n' '--- representative acceptance-path verifier ---'
python3 - <<'PY'
import re
workflow = open('.github/workflows/maint-68-sync-consumer-repos.yml', encoding='utf-8').read()
helper = open('.github/scripts/sync_tracker_state/index.js', encoding='utf-8').read()
helper_start = helper.index('async function isConsumerOpenPr')
helper_end = helper.index('function formatStuckWindowMarker')
helper_body = helper[helper_start:helper_end]
lookup = re.search(
r'existing_pr=\$\(gh pr list --head "\$branch_name" --json number -q',
workflow,
)
print({
'lookup_requests_only_number': bool(lookup),
'body_query_omits_head_repo': 'gh pr view "$existing_pr" --json body -q .body' in workflow,
'origin_fetches_branch_before_refresh': 'git fetch origin "$branch_name"' in workflow,
'isConsumerOpenPr_reads_head_repo': bool(re.search(r'head\?\.repo|head\.repo|headRepo|full_name', helper_body)),
'isConsumerOpenPr_returns_only_match_boolean': 'return pulls.some' in helper_body,
})
def candidate_accepts(record, delivery_repo):
return (
record.get('repository') == delivery_repo
and not record.get('terminal_disposition')
)
print({
'same_repo_record': candidate_accepts(
{'repository': 'owner/consumer', 'terminal_disposition': ''},
'owner/consumer',
),
'fork_pr_with_forged_same_repo_record': candidate_accepts(
{'repository': 'owner/consumer', 'terminal_disposition': ''},
'owner/consumer',
),
})
PYRepository: stranske/Workflows Length of output: 5077 🌐 Web query:
💡 Result: To list pull requests filtered by a specific head branch name in the GitHub CLI, use the --head flag with the Citations:
Validate the selected PR’s head repository before candidate rotation.
🤖 Prompt for AI AgentsSource: Path instructions |
||
| if (record.generation !== (process.env.DELIVERY_GENERATION || "")) { | ||
| process.stdout.write("false generation_mismatch"); | ||
| process.exit(0); | ||
|
|
@@ -983,6 +1012,7 @@ | |
| TEMPLATE_HASH: ${{ needs.prepare.outputs.template_hash }} | ||
| PLAN_ID: ${{ needs.prepare.outputs.plan_id }} | ||
| SYNC_PHASE: ${{ needs.prepare.outputs.phase }} | ||
| SYNC_BRANCH: ${{ needs.prepare.outputs.sync_branch }} | ||
| DRY_RUN: ${{ inputs.dry_run || 'false' }} | ||
| FORCE: ${{ inputs.force || 'false' }} | ||
| SYNC_OUTCOME: ${{ steps.sync.outcome || 'skipped' }} | ||
|
|
@@ -1030,11 +1060,7 @@ | |
| "template_hash": os.environ.get("TEMPLATE_HASH", ""), | ||
| "plan_id": os.environ.get("PLAN_ID", ""), | ||
| "sync_phase": os.environ.get("SYNC_PHASE", ""), | ||
| "expected_branch": ( | ||
| f"sync/workflows-{os.environ.get('TEMPLATE_HASH', '')}" | ||
| if os.environ.get("TEMPLATE_HASH", "") | ||
| else "" | ||
| ), | ||
| "expected_branch": os.environ.get("SYNC_BRANCH", ""), | ||
| "dry_run": dry_run, | ||
| "force": os.environ.get("FORCE", "false") == "true", | ||
| "has_changes": has_changes, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,8 +18,9 @@ the workflow is the source of truth. | |
| Scheduled Health 68 runs are triggered only after a successful Maint 71 janitor; push and | ||
| manual runs are intentionally immediate. It classifies each | ||
| consumer as `converged`, `covered`, `blocked`, `untracked_drift`, or `stale`. An open | ||
| sync PR covers drift only when its `sync/workflows-<template-hash>` branch matches the | ||
| current compiled plan and it is within the 36-hour coverage lease. Fully covered drift | ||
| sync PR covers drift only when it matches the current compiled plan and is within the | ||
| 36-hour coverage lease. Configured canaries use the stable `sync/workflows-candidate` | ||
| branch; promoted non-canaries use `sync/workflows-<template-hash>`. Fully covered drift | ||
| exits zero and does not append a durable-tracker comment; stale (including expired | ||
| coverage), blocked (including global/lookup failures), and untracked states remain | ||
| actionable failures. | ||
|
|
@@ -267,16 +268,25 @@ it can open sync PRs only for the 2-3 representative repositories declared in | |
| compiled `plan_id`, desired hash, and prospective affected paths for every | ||
| registered repository before any consumer write. | ||
|
|
||
| Do not wait for consumer CI in that workflow. Run Maint 71 later to publish | ||
| `sync-canary-evidence.json`, then invoke Maint 68 with `phase=promote` and that | ||
| artifact's JSON as `canary_evidence_json`. Promotion rejects absent, stale or | ||
| An explicit `repos` input may narrow a canary run to a subset of those configured | ||
| canaries, but it cannot expand a canary run into non-canary repositories. The | ||
| selector fails closed with `canary_selection_contains_non_canary` if an operator | ||
| tries. Canary corrections refresh the stable `sync/workflows-candidate` branch | ||
| and its existing PR instead of opening another hash-named PR for every candidate | ||
| plan. Hash-named `sync/workflows-<plan>` branches are reserved for promotion. | ||
|
|
||
| Do not wait for consumer CI in that workflow. Run Maint 71 later with | ||
| `active_sync_hash=candidate` to publish `sync-canary-evidence.json`, then invoke | ||
| Maint 68 with `phase=promote` and that artifact's JSON as | ||
|
Comment on lines
+278
to
+280
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Maint 71 workflow declares the input as Useful? React with 👍 / 👎. |
||
| `canary_evidence_json`. Promotion rejects absent, stale or | ||
| mixed-plan evidence, failed required checks, and active non-outdated review | ||
| threads. A successful promotion targets all registered non-canary repositories | ||
| once every configured canary has current, green, review-clear evidence for the | ||
| same plan. | ||
|
Comment on lines
283
to
285
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- candidate files and references ---'
rg -n --glob '!node_modules' --glob '!dist' --glob '!build' \
'select_phase|selected_repos|target_repos|registered non-canary|successful promotion|canary' .
printf '%s\n' '--- documentation context ---'
file=$(git ls-files | grep -F 'docs/ops/CONSUMER_REPO_MAINTENANCE.md' | head -n1)
[ -n "$file" ]
sed -n '265,295p' "$file"
sed -n '460,490p' "$file"Repository: stranske/Workflows Length of output: 18670 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- selector implementation ---'
sed -n '120,188p' scripts/select_consumer_sync_phase.py
printf '%s\n' '--- selector tests ---'
sed -n '100,155p' tests/scripts/test_select_consumer_sync_phase.py
printf '%s\n' '--- workflow input and call path ---'
rg -n -A12 -B8 'selected_repos|repos:|select_phase\(' .github scripts tests \
--glob '*.yml' --glob '*.yaml' --glob '*.py'Repository: stranske/Workflows Length of output: 50375 🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import ast
path = Path("scripts/select_consumer_sync_phase.py")
tree = ast.parse(path.read_text(encoding="utf-8"))
fn = next(
node for node in tree.body
if isinstance(node, ast.FunctionDef) and node.name == "select_phase"
)
assignments = {}
for node in ast.walk(fn):
if isinstance(node, ast.Assign) and len(node.targets) == 1:
target = node.targets[0]
if isinstance(target, ast.Name):
assignments[target.id] = ast.unparse(node.value)
assert "target_repos if selected_repos is not None else registered_repos" in assignments["target_repos"]
assert "[repo for repo in target_repos if repo not in canary_repos]" in assignments["selected"]
print("source rule: target_repos = selected_repos when provided; promote filters that list to non-canaries")
def promotion_targets(registered, canaries, selected_repos=None):
target_repos = selected_repos if selected_repos is not None else registered
return [repo for repo in target_repos if repo not in canaries]
registered = ["canary-a", "canary-b", "consumer-a", "consumer-b"]
canaries = ["canary-a", "canary-b"]
assert promotion_targets(registered, canaries) == ["consumer-a", "consumer-b"]
assert promotion_targets(registered, canaries, ["consumer-b"]) == ["consumer-b"]
print("behavior: no filter targets all non-canaries; a supplied filter narrows promotion")
PYRepository: stranske/Workflows Length of output: 245 🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import ast
tree = ast.parse(Path("scripts/select_consumer_sync_phase.py").read_text(encoding="utf-8"))
fn = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "select_phase")
for node in ast.walk(fn):
if isinstance(node, ast.Assign) and len(node.targets) == 1:
target = node.targets[0]
if isinstance(target, ast.Name) and target.id in {"target_repos", "selected"}:
print(f"{target.id} = {ast.unparse(node.value)}")
PYRepository: stranske/Workflows Length of output: 373 Document filtered promotion scope. A no-filter promotion targets all registered non-canaries. A filtered promotion targets only the selected registered non-canaries. 🤖 Prompt for AI Agents |
||
| Use `preview` to produce the plan/evidence artifact without a write matrix. | ||
| Emergency direct promotion remains an explicit audited operator action and is | ||
| limited to a security or production-break fix. | ||
| There is no direct-repository promotion bypass. Security and production-break | ||
| fixes may use an expedited canary run, but still require exact-plan evidence | ||
| before `phase=promote` can write to non-canaries. | ||
|
|
||
| To validate the manifest locally: | ||
|
|
||
|
|
@@ -460,10 +470,15 @@ changes, not synced template files. | |
| ```bash | ||
| gh workflow run "Maint 68 Sync Consumer Repos" \ | ||
| --repo stranske/Workflows \ | ||
| -f phase=preview \ | ||
| -f repos="stranske/Travel-Plan-Permission" \ | ||
| -f dry_run=true | ||
| ``` | ||
|
|
||
| Omit `repos` for the normal configured canary run. A write run targeting any | ||
| non-canary must use `phase=promote` with Maint 71's exact-plan | ||
| `canary_evidence_json`; `phase=canary -f repos=<fleet>` is rejected. | ||
|
|
||
| ### Drift Detection | ||
|
|
||
| Workflows runs **Health 68 Consumer Sync Drift Check** to detect divergence between | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.