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
7 changes: 7 additions & 0 deletions .github/scripts/__tests__/sync-pr-merge-contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const {
buildMergeReport,
classifySyncPrChecks,
collectDeletableSyncBranches,
isTrustedSyncPr,
normalizeSyncHash,
parseBooleanInput,
selectActiveSyncPr,
Expand Down Expand Up @@ -60,6 +61,12 @@ test('selectActiveSyncPr falls back to newest sync PR without a target hash', ()
assert.equal(selection.missingExpected, false);
});

test('isTrustedSyncPr requires the configured actor and sync branch', () => {
const trusted = { ...pr(1, 'sync/workflows-current', '2026-04-25T01:00:00Z'), user: { login: 'stranske' } };
assert.equal(isTrustedSyncPr(trusted, ['stranske']), true);
assert.equal(isTrustedSyncPr({ ...trusted, user: { login: 'untrusted' } }, ['stranske']), false);
});

test('selectActiveSyncPr honors target hash instead of newest PR', () => {
const selection = selectActiveSyncPr(
[
Expand Down
6 changes: 6 additions & 0 deletions .github/scripts/sync_pr_merge_contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ function isSyncBranchName(value) {
return branchNameFromRef(value).startsWith(SYNC_BRANCH_PREFIX);
}

function isTrustedSyncPr(pr, trustedActors = []) {
const actor = String(pr?.user?.login || '').trim();
return isSyncBranchName(pr?.head?.ref) && new Set(trustedActors).has(actor);
}

function parseBooleanInput(value, defaultValue = false) {
if (value === undefined || value === null || String(value).trim() === '') {
return Boolean(defaultValue);
Expand Down Expand Up @@ -262,6 +267,7 @@ module.exports = {
classifySyncPrChecks,
collectDeletableSyncBranches,
isSyncBranchName,
isTrustedSyncPr,
normalizeSyncHash,
syncBranchForHash,
parseBooleanInput,
Expand Down
78 changes: 59 additions & 19 deletions .github/workflows/maint-68-sync-consumer-repos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ on:
description: 'Force sync even if no changes detected'
type: boolean
default: false
phase:
description: 'Run preview (read-only), canary, or promote after green canary evidence'
type: choice
options: [preview, canary, promote]
default: canary
canary_evidence_json:
description: 'Maint 71 canary evidence JSON for the exact plan ID (required to promote)'
type: string
required: false

permissions:
contents: read
Expand Down Expand Up @@ -72,6 +81,8 @@ jobs:
outputs:
repos: ${{ steps.repos.outputs.matrix }}
template_hash: ${{ steps.manifest.outputs.template_hash }}
plan_id: ${{ steps.manifest.outputs.plan_id }}
phase: ${{ steps.repos.outputs.phase }}
steps:
- name: Checkout
uses: actions/checkout@v7
Expand Down Expand Up @@ -108,30 +119,46 @@ jobs:
--output-json manifest.json \
--github-output "$GITHUB_OUTPUT"

- name: Build repo matrix
- name: Build phase-gated repo matrix
id: repos
env:
CANARY_EVIDENCE_JSON: ${{ inputs.canary_evidence_json || '' }}
REPOS_INPUT: ${{ inputs.repos }}
run: |
if [ -n "${{ inputs.repos }}" ]; then
repos='${{ inputs.repos }}'
if [ -n "$REPOS_INPUT" ]; then
repos="$REPOS_INPUT"
else
repos=$(echo "$REGISTERED_CONSUMER_REPOS" | tr '\n' ',' | sed 's/,$//')
fi
json_array=$(echo "$repos" \
| tr ',' '\n' \
| grep -v '^$' \
| jq -R -s -c '
split("\n")
| map(select(. != ""))
| map(gsub("^\\s+|\\s+$"; ""))
')
echo "matrix={\"repo\":$json_array}" >> "$GITHUB_OUTPUT"
echo "Repos to sync: $json_array"

- name: Upload manifest artifact

# Scheduled and release runs intentionally begin with canaries. Promotion is
# a later explicit run with Maint 71's plan-bound evidence; no workflow waits
# for cross-repo CI inside this run.
phase='${{ inputs.phase || 'canary' }}'
selected_repos_args=()
if [ -n "$REPOS_INPUT" ]; then
selected_repos_args=(--selected-repos "$repos")
fi
python scripts/select_consumer_sync_phase.py \
--plan manifest.json \
--phase "$phase" \
--registered-repos "$(echo "$REGISTERED_CONSUMER_REPOS" | tr '\n' ',' | sed 's/,$//')" \
"${selected_repos_args[@]}" \
--canaries config/consumer_sync_canaries.json \
--canary-evidence-json "$CANARY_EVIDENCE_JSON" \
--output sync-phase-selection.json
echo "matrix=$(jq -c '.matrix' sync-phase-selection.json)" >> "$GITHUB_OUTPUT"
echo "phase=$phase" >> "$GITHUB_OUTPUT"
echo "Selected phase: $phase"
jq -r '.selected_repos[]' sync-phase-selection.json
Comment thread
stranske marked this conversation as resolved.

- name: Upload plan and prospective-diff evidence
uses: actions/upload-artifact@v7
with:
name: sync-manifest
path: manifest.json
name: sync-plan-and-prospective-diffs
Comment thread
stranske marked this conversation as resolved.
path: |
manifest.json
sync-phase-selection.json
retention-days: 1
Comment thread
stranske marked this conversation as resolved.

# ============================================================================
Expand All @@ -156,7 +183,7 @@ jobs:
- name: Download manifest
uses: actions/download-artifact@v8
with:
name: sync-manifest
name: sync-plan-and-prospective-diffs

- name: Validate Python scripts
run: |
Expand Down Expand Up @@ -224,13 +251,16 @@ jobs:
sync:
name: Sync ${{ matrix.repo }}
needs: [prepare, validate]
if: needs.prepare.outputs.phase != 'preview'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.prepare.outputs.repos) }}
env:
# Job-level alias for repo token (uses OWNER_PR_PAT or SERVICE_BOT_PAT)
REPO_TOKEN: ${{ secrets.OWNER_PR_PAT || secrets.SERVICE_BOT_PAT }}
PLAN_ID: ${{ needs.prepare.outputs.plan_id }}
SYNC_PHASE: ${{ needs.prepare.outputs.phase }}
steps:
- name: Compute result artifact name
id: repo_meta
Expand All @@ -253,7 +283,7 @@ jobs:
- name: Download manifest
uses: actions/download-artifact@v8
with:
name: sync-manifest
name: sync-plan-and-prospective-diffs

- name: Setup Python
uses: actions/setup-python@v7
Expand Down Expand Up @@ -812,6 +842,8 @@ jobs:
--arg source_repository "$GITHUB_REPOSITORY" \
--arg source_sha "$GITHUB_SHA" \
--arg template_hash "${{ needs.prepare.outputs.template_hash }}" \
--arg plan_id "$PLAN_ID" \
--arg sync_phase "$SYNC_PHASE" \
--arg sync_branch "$branch_name" \
--arg manifest ".github/sync-manifest.yml" \
--arg lifecycle_state "opened" \
Expand All @@ -824,6 +856,8 @@ jobs:
source_repository: $source_repository,
source_sha: $source_sha,
template_hash: $template_hash,
plan_id: $plan_id,
sync_phase: $sync_phase,
sync_branch: $sync_branch,
manifest: $manifest,
lifecycle_state: $lifecycle_state,
Expand All @@ -843,6 +877,8 @@ jobs:
**Source:** stranske/Workflows
**Source SHA:** \`$GITHUB_SHA\`
**Template hash:** \`${{ needs.prepare.outputs.template_hash }}\`
**Consumer-sync plan ID:** \`$PLAN_ID\`
**Sync phase:** \`$SYNC_PHASE\`
**Sync branch:** \`$branch_name\`
**Consumer repo:** \`${{ matrix.repo }}\`
**Manifest:** \`.github/sync-manifest.yml\`
Expand Down Expand Up @@ -878,6 +914,8 @@ jobs:
env:
REPO: ${{ matrix.repo }}
TEMPLATE_HASH: ${{ needs.prepare.outputs.template_hash }}
PLAN_ID: ${{ needs.prepare.outputs.plan_id }}
SYNC_PHASE: ${{ needs.prepare.outputs.phase }}
DRY_RUN: ${{ inputs.dry_run || 'false' }}
FORCE: ${{ inputs.force || 'false' }}
SYNC_OUTCOME: ${{ steps.sync.outcome || 'skipped' }}
Expand Down Expand Up @@ -923,6 +961,8 @@ jobs:
"repo": repo,
"status": status,
"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", "")
Expand Down
80 changes: 76 additions & 4 deletions .github/workflows/maint-71-merge-sync-prs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ jobs:
DRY_RUN_INPUT: ${{ inputs.dry_run }}
SYNC_HASH_INPUT: ${{ inputs.sync_hash || '' }}
CLEANUP_BRANCHES_INPUT: ${{ inputs.cleanup_branches }}
TRUSTED_SYNC_ACTORS: stranske,stranske-automation-bot,github-actions[bot]
SYNC_PR_MERGE_REPORT_JSON: artifacts/sync-pr-merge-report.json
with:
github-token: ${{ secrets.OWNER_PR_PAT || secrets.SERVICE_BOT_PAT || github.token }}
Expand All @@ -137,6 +138,7 @@ jobs:
collectDeletableSyncBranches,
normalizeSyncHash,
parseBooleanInput,
isTrustedSyncPr,
selectActiveSyncPr,
selectSyncPrGatingChecks,
} = require('./.github/scripts/sync_pr_merge_contract.js');
Expand Down Expand Up @@ -250,8 +252,11 @@ jobs:
const requestedSyncHash = normalizeSyncHash(
process.env.SYNC_HASH_INPUT ||
(context.payload.client_payload && context.payload.client_payload.sync_hash) ||
'',
'',
);
const trustedSyncActors = process.env.TRUSTED_SYNC_ACTORS.split(',')
.map((actor) => actor.trim())
.filter(Boolean);

console.log(`Registered consumer repos: ${registeredRepos.join(', ')}`);
console.log(`Processing repos: ${targetRepos.join(', ')}`);
Expand All @@ -262,6 +267,50 @@ jobs:
}

const results = [];
const canaryEvidence = [];

function syncMetadata(pr) {
const match = String(pr.body || '').match(
/<!-- workflows-consumer-sync:v1 ([\s\S]*?) -->/,
);
if (!match) return null;
try {
return JSON.parse(match[1]);
} catch (_) {
return null;
}
}

async function activeReviewThreadCount(owner, repo, number) {
try {
const data = await github.graphql(
`query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
reviewThreads(first: 100) {
pageInfo { hasNextPage }
nodes { isResolved isOutdated }
}
}
}
}`,
{ owner, repo, number },
);
const reviewThreads = data.repository.pullRequest.reviewThreads;
if (reviewThreads.pageInfo.hasNextPage) {
core.warning(
`Review-thread pagination exceeded the safe evidence window for ${owner}/${repo}#${number}`,
);
return -1;
}
return reviewThreads.nodes.filter(
(thread) => !thread.isResolved && !thread.isOutdated,
).length;
} catch (error) {
core.warning(`Unable to read active review threads for ${owner}/${repo}#${number}: ${error}`);
return -1;
}
}
Comment thread
stranske marked this conversation as resolved.

for (const repoEntry of targetRepos) {
const [entryOwner, entryRepo] = repoEntry.includes('/')
Expand All @@ -281,7 +330,7 @@ jobs:
per_page: 20
}));

const syncPRs = prs.filter(pr => pr.head.ref.startsWith('sync/workflows-'));
const syncPRs = prs.filter((pr) => isTrustedSyncPr(pr, trustedSyncActors));

if (cleanupBranches) {
try {
Expand Down Expand Up @@ -448,6 +497,7 @@ jobs:

// Process the selected active PR
const pr = selection.active;
const metadata = syncMetadata(pr);
console.log(`\nProcessing active PR #${pr.number}: ${pr.title}`);
console.log(`Branch: ${pr.head.ref}`);
console.log(`Created: ${pr.created_at}`);
Expand Down Expand Up @@ -489,6 +539,17 @@ jobs:
const failedChecks = classification.failed;
const pendingChecks = classification.pending;

if (metadata?.sync_phase === 'canary' && metadata?.plan_id) {
canaryEvidence.push({
repo: `${owner}/${repo}`,
plan_id: metadata.plan_id,
pr: pr.number,
required_check_state:
classification.status === 'ready' ? 'success' : classification.status,
active_review_thread_count: await activeReviewThreadCount(owner, repo, pr.number),
});
}

console.log(
`Checks (${checkGateMode}): ${gatingChecks.length} gating, ` +
`${failedChecks.length} failed, ${pendingChecks.length} pending`,
Expand Down Expand Up @@ -686,6 +747,15 @@ jobs:
const reportPath = process.env.SYNC_PR_MERGE_REPORT_JSON;
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
fs.writeFileSync(
'artifacts/sync-canary-evidence.json',
`${JSON.stringify({
schema: 'workflows.consumer-sync-canary-evidence/v1',
version: 1,
results: canaryEvidence,
}, null, 2)}\n`,
'utf8',
);
await core.summary.addRaw(buildMarkdownSummary(report)).write();

const merged = report.summary.merged;
Expand Down Expand Up @@ -735,6 +805,8 @@ jobs:
if: always()
uses: actions/upload-artifact@v7
with:
name: sync-pr-merge-report
path: artifacts/sync-pr-merge-report.json
name: sync-pr-merge-and-canary-evidence
path: |
artifacts/sync-pr-merge-report.json
artifacts/sync-canary-evidence.json
if-no-files-found: warn
18 changes: 18 additions & 0 deletions config/consumer_sync_canaries.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"schema": "workflows.consumer-sync-canaries/v1",
"version": 1,
"canaries": [
{
"repo": "stranske/Travel-Plan-Permission",
"capabilities": ["standard", "custom-gate"]
},
{
"repo": "stranske/trip-planner",
"capabilities": ["lock-heavy", "node-tooling"]
},
{
"repo": "stranske/Manager-Database",
"capabilities": ["standard", "python-consumer"]
}
]
}
4 changes: 2 additions & 2 deletions docs/WORKFLOW_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@ _Inline Gate helper_
- **`maint-62-integration-consumer.yml`** — Nightly + release-triggered integration tests that reuse `reusable-10-ci-python.yml` across multiple matrices and file/resolve the `integration-test` issue via the load-balanced API client (no extra app mint).
- **`maint-65-sync-label-docs.yml`** — Syncs `docs/LABELS.md` into every registered consumer repo (plus the integration tests repo) when the source doc changes or on demand, using the shared registered-repo helper and PAT gating for cross-repo pushes.
- **`maint-66-monthly-audit.yml`** — First-of-month workflow that gathers workflow-run stats, runs the API wrapper guard, and files/updates the monthly audit issue; relies on the shared API client so no extra npm installs or App-token mints are needed.
- **`maint-68-sync-consumer-repos.yml`** — Manifest-driven consumer sync that validates template/scripts, hashes the template set, and opens PAT-backed sync PRs for each registered repo; the jobs now rely solely on the shared API client and repo PATs (no extra App token mints).
- **`maint-68-sync-consumer-repos.yml`** — Manifest-driven consumer sync that validates template/scripts, hashes the template set, records a prospective per-repo matrix, and opens PAT-backed sync PRs. Normal no-filter runs start with the configured canaries; a later `promote` run requires same-plan, green, review-clear Maint 71 evidence before it targets non-canaries. The jobs rely solely on the shared API client and repo PATs (no extra App token mints).
- **`maint-69-sync-integration-repo.yml`** — Keeps Workflows-Integration-Tests aligned with `templates/integration-repo/`, regenerates `requirements.lock`, and pushes updates using PATs; no GitHub App token mint is required because the workflow stays inside the two repos.
- **`maint-69-sync-labels.yml`** — Propagates the canonical `.github/labels-core.yml` set to every registered consumer repo (or a provided subset), reusing the registered-repo helper + load-balanced API client without any additional App-token minting.
- **`maint-70-fix-integration-formatting.yml`** — Manual formatter for Workflows-Integration-Tests that resolves the repo default branch, applies `black`+`ruff` fixes, and pushes via PAT only when a token is available; runs read-only otherwise.
- **`maint-71-auto-fix-integration.yml`** — Auto-triggered integration fixer that watches “Integration CI failed” issues/comments, re-runs the formatting routine, and pushes via PAT when available (otherwise posting a skipped note).
- **`maint-71-merge-sync-prs.yml`** — Scans each registered consumer repo for open `sync/workflows-*` PRs, closes stale duplicates, deletes leftover same-repo sync branches tied to closed/merged sync PRs, and (optionally) auto-merges passing PRs using the shared repo helper + PATs. PRs carrying runtime-AC labels are not merged by this external lane; they must pass through the local Orchestrator runtime AC guard.
- **`maint-71-merge-sync-prs.yml`** — Scans each registered consumer repo for open `sync/workflows-*` PRs, closes stale duplicates, deletes leftover same-repo sync branches tied to closed/merged sync PRs, and (optionally) auto-merges passing PRs using the shared repo helper + PATs. It emits plan-bound canary evidence with the PR/check/review state for Maint 68 promotion. PRs carrying runtime-AC labels are not merged by this external lane; they must pass through the local Orchestrator runtime AC guard.
- **`maint-72-fix-pr-body-conflicts.yml`** — Periodically removes stray `pr_body.md` files from consumer repos and ensures `.gitignore` blocks them, reusing the registered-repo helper + PAT discovery so cleanups only run when push access is available.
- **`maint-74-ledger-base-sync.yml`** — Keeps `.agents` ledger base entries aligned with the repo’s default branch by running `scripts/ledger_migrate_base.py` and opening a helper PR (no extra App token mint needed).
- **`maint-auto-update-pypi-versions.yml`** — Daily PyPI watcher that updates `autofix-versions.env`, regenerates supporting files, and opens a PR when new tool versions land (runs entirely with the default token + GH CLI).
Expand Down
Loading
Loading