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
19 changes: 19 additions & 0 deletions .github/scripts/__tests__/sync-pr-merge-contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const {
normalizeSyncHash,
parseBooleanInput,
selectActiveSyncPr,
selectMergeEligibleSyncPr,
summarizeResults,
syncBranchForHash,
} = require('../sync_pr_merge_contract');
Expand Down Expand Up @@ -92,6 +93,23 @@ test('selectActiveSyncPr reports missing target without marking stale PRs', () =
assert.equal(selection.missingExpected, true);
});

test('selectMergeEligibleSyncPr refuses legacy delivery attempts', () => {
const legacy = pr(1, 'sync/workflows-current', '2026-04-25T01:00:00Z');
assert.equal(selectMergeEligibleSyncPr([legacy]).eligibility.reason, 'missing_delivery_record');
});

test('selectMergeEligibleSyncPr rejects a PR whose head no longer matches its lease', () => {
const leased = {
...pr(1, 'sync/workflows-current', '2026-04-25T01:00:00Z'),
body: '<!-- sync-pr-delivery-record:v1 {"schema":"sync-pr-delivery-record/v1","durable_issue_url":"https://github.com/stranske/Workflows/issues/1836","plan_id":"plan-abc","generation":"template-abc","repository":"stranske/Ready","desired_tree_hash":"tree-abc","source_commit":"source-abc","lease_expires_at":"2026-08-02T00:00:00Z","predecessor_prs":[],"successor_prs":[]} -->',
};
assert.equal(selectMergeEligibleSyncPr([leased], {
now: '2026-08-01T22:00:00Z',
repository: 'stranske/Ready',
desiredTreeHash: 'tree-other',
}).eligibility.reason, 'desired_tree_mismatch');
});

test('buildMergeReport provides machine-readable summary counts', () => {
const report = buildMergeReport({
generatedAt: '2026-04-25T06:00:00Z',
Expand Down Expand Up @@ -122,6 +140,7 @@ test('buildMergeReport provides machine-readable summary counts', () => {
merge_blocked_runtime_ac: 0,
merged: 0,
merge_failed: 0,
delivery_contract_blocked: 0,
error: 0,
});
});
Expand Down
39 changes: 39 additions & 0 deletions .github/scripts/__tests__/sync_pr_lease_contract.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use strict';

const test = require('node:test');
const assert = require('node:assert/strict');
const {
DELIVERY_RECORD_SCHEMA,
formatDeliveryRecord,
parseDeliveryRecord,
mergeEligibility,
} = require('../sync_pr_lease_contract');

const current = {
schema: DELIVERY_RECORD_SCHEMA,
durable_issue_url: 'https://github.com/stranske/Workflows/issues/1836',
plan_id: 'plan-abc',
generation: 'template-abc',
repository: 'stranske/Ready',
desired_tree_hash: 'tree-abc',
source_commit: 'source-abc',
lease_expires_at: '2026-08-02T00:00:00Z',
predecessor_prs: ['#10'],
successor_prs: [],
};

test('an unexpired matching delivery record is merge eligible', () => {
const marker = formatDeliveryRecord(current);
const parsed = parseDeliveryRecord(`summary\n${marker}`);
assert.deepEqual(parsed, { ...current, terminal_disposition: '' });
assert.deepEqual(mergeEligibility(parsed, {
now: '2026-08-01T22:00:00Z',
planId: 'plan-abc',
repository: 'stranske/Ready',
desiredTreeHash: 'tree-abc',
}), { eligible: true, reason: 'current_unexpired' });
assert.equal(mergeEligibility({ ...parsed, lease_expires_at: '2026-08-01T21:00:00Z' }, {
now: '2026-08-01T22:00:00Z',
}).reason, 'lease_expired');
assert.equal(mergeEligibility(parsed, { now: '2026-08-01T22:00:00Z', desiredTreeHash: 'other' }).reason, 'desired_tree_mismatch');
});
90 changes: 90 additions & 0 deletions .github/scripts/sync_pr_lease_contract.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
'use strict';

// A generated PR is a short-lived delivery attempt. The durable campaign issue
// retains coordination history; this marker lets the producer and merger agree
// on which attempt is current without treating an arbitrary open PR as current.
const DELIVERY_RECORD_SCHEMA = 'sync-pr-delivery-record/v1';
const DELIVERY_RECORD_MARKER = 'sync-pr-delivery-record:v1';

function clean(value) {
return String(value || '').trim();
}

function unique(values) {
return [...new Set((values || []).map(clean).filter(Boolean))];
}

function normalizeRecord(record = {}) {
const normalized = {
schema: clean(record.schema) || DELIVERY_RECORD_SCHEMA,
durable_issue_url: clean(record.durable_issue_url),
plan_id: clean(record.plan_id),
generation: clean(record.generation),
repository: clean(record.repository),
desired_tree_hash: clean(record.desired_tree_hash),
source_commit: clean(record.source_commit),
lease_expires_at: clean(record.lease_expires_at),
predecessor_prs: unique(record.predecessor_prs),
successor_prs: unique(record.successor_prs),
terminal_disposition: clean(record.terminal_disposition),
};
return normalized;
}

function deliveryRecordErrors(record = {}) {
const normalized = normalizeRecord(record);
const required = [
'durable_issue_url', 'plan_id', 'generation', 'repository',
'desired_tree_hash', 'source_commit', 'lease_expires_at',
];
const errors = [];
if (normalized.schema !== DELIVERY_RECORD_SCHEMA) errors.push('schema');
for (const field of required) if (!normalized[field]) errors.push(field);
if (normalized.terminal_disposition && !['merged', 'superseded', 'expired', 'blocked'].includes(normalized.terminal_disposition)) {
errors.push('terminal_disposition');
}
if (normalized.lease_expires_at && Number.isNaN(Date.parse(normalized.lease_expires_at))) {
errors.push('lease_expires_at');
}
return errors;
}

function formatDeliveryRecord(record = {}) {
const normalized = normalizeRecord(record);
const errors = deliveryRecordErrors(normalized);
if (errors.length) throw new Error(`Invalid delivery record: ${errors.join(', ')}`);
return `<!-- ${DELIVERY_RECORD_MARKER} ${JSON.stringify(normalized)} -->`;
}

function parseDeliveryRecord(body = '') {
const match = String(body || '').match(new RegExp(`<!--\\s*${DELIVERY_RECORD_MARKER}\\s+([\\s\\S]*?)\\s*-->`));
if (!match) return null;
try {
const record = normalizeRecord(JSON.parse(match[1]));
return deliveryRecordErrors(record).length ? null : record;
} catch (_) {
return null;
}
}

function mergeEligibility(record, { now = new Date().toISOString(), planId = '', repository = '', desiredTreeHash = '' } = {}) {
const normalized = normalizeRecord(record);
const errors = deliveryRecordErrors(normalized);
if (errors.length) return { eligible: false, reason: `invalid:${errors.join(',')}` };
if (normalized.terminal_disposition) return { eligible: false, reason: `terminal:${normalized.terminal_disposition}` };
if (Date.parse(normalized.lease_expires_at) <= Date.parse(now)) return { eligible: false, reason: 'lease_expired' };
if (clean(planId) && normalized.plan_id !== clean(planId)) return { eligible: false, reason: 'plan_mismatch' };
if (clean(repository) && normalized.repository !== clean(repository)) return { eligible: false, reason: 'repository_mismatch' };
if (clean(desiredTreeHash) && normalized.desired_tree_hash !== clean(desiredTreeHash)) return { eligible: false, reason: 'desired_tree_mismatch' };
return { eligible: true, reason: 'current_unexpired' };
}

module.exports = {
DELIVERY_RECORD_SCHEMA,
DELIVERY_RECORD_MARKER,
normalizeRecord,
deliveryRecordErrors,
formatDeliveryRecord,
parseDeliveryRecord,
mergeEligibility,
};
16 changes: 16 additions & 0 deletions .github/scripts/sync_pr_merge_contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const REPORT_SCHEMA = 'workflows-sync-pr-merge/v1';
const SYNC_BRANCH_PREFIX = 'sync/workflows-';
const { parseDeliveryRecord, mergeEligibility } = require('./sync_pr_lease_contract');

function normalizeSyncHash(value) {
const raw = String(value || '').trim();
Expand Down Expand Up @@ -179,6 +180,19 @@ function selectActiveSyncPr(prs, syncHash = '') {
};
}

function selectMergeEligibleSyncPr(
prs,
{ syncHash = '', now, planId = '', repository = '', desiredTreeHash = '' } = {},
) {
const selection = selectActiveSyncPr(prs, syncHash);
if (!selection.active) return { ...selection, eligibility: null };
const record = parseDeliveryRecord(selection.active.body || '');
const eligibility = record
? mergeEligibility(record, { now, planId, repository, desiredTreeHash })
: { eligible: false, reason: 'missing_delivery_record' };
return { ...selection, deliveryRecord: record, eligibility };
}

function summarizeResults(results) {
const counts = {
no_prs: 0,
Expand All @@ -194,6 +208,7 @@ function summarizeResults(results) {
merge_blocked_runtime_ac: 0,
merged: 0,
merge_failed: 0,
delivery_contract_blocked: 0,
error: 0,
};
for (const result of results || []) {
Expand Down Expand Up @@ -274,6 +289,7 @@ module.exports = {
selectSyncPrGatingChecks,
sortSyncPrs,
selectActiveSyncPr,
selectMergeEligibleSyncPr,
summarizeResults,
buildMergeReport,
buildMarkdownSummary,
Expand Down
61 changes: 41 additions & 20 deletions .github/workflows/maint-52-sync-dev-versions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -272,10 +272,13 @@ jobs:
&& inputs.dry_run != true
env:
GH_TOKEN: ${{ env.REPO_TOKEN }}
DELIVERY_PLAN_ID: dev-tool-${{ needs.prepare.outputs.versions_hash }}
DELIVERY_GENERATION: ${{ needs.prepare.outputs.versions_hash }}
DELIVERY_REPOSITORY: ${{ matrix.repo }}
run: |
cd consumer

branch_name="deps/sync-dev-versions-${{ needs.prepare.outputs.versions_hash }}"
branch_name="deps/sync-dev-versions-$DELIVERY_GENERATION"

# Preserve the PR for this wave when it already exists, but rebuild
# its branch from current main if either the base or generated tree
Expand All @@ -302,32 +305,30 @@ jobs:
if [ -f requirements-dev.txt ]; then git add requirements-dev.txt; fi
if [ -f uv.lock ]; then git add uv.lock; fi

matching_existing=false
if [ -n "$existing_pr" ]; then
git fetch origin "$branch_name"
existing_head=$(git rev-parse FETCH_HEAD)
existing_base=$(git rev-parse "${existing_head}^")
existing_tree=$(git rev-parse "${existing_head}^{tree}")
desired_tree=$(git write-tree)
if [ "$existing_base" = "$base_sha" ] && [ "$existing_tree" = "$desired_tree" ]; then
echo "Existing PR #$existing_pr already matches current main and generated files"
exit 0
matching_existing=true
echo "Existing PR #$existing_pr already matches current main and generated files; refreshing lease"
fi
fi

# Commit with multi-line message
commit_msg="deps: sync dev tool versions from Workflows
if [ "$matching_existing" != "true" ]; then
# Commit with multi-line message
commit_msg="deps: sync dev tool versions from Workflows

Automated sync from stranske/Workflows autofix-versions.env
Versions hash: ${{ needs.prepare.outputs.versions_hash }}
Automated sync from stranske/Workflows autofix-versions.env
Versions hash: $DELIVERY_GENERATION

This ensures consistent dev tool versions across all repos."
git commit -m "$commit_msg"
This ensures consistent dev tool versions across all repos."
git commit -m "$commit_msg"

git push --force -u origin "$branch_name"

if [ -n "$existing_pr" ]; then
echo "Updated existing PR #$existing_pr for this wave"
exit 0
git push --force -u origin "$branch_name"
fi

changed_files="$(git show --name-only --format= HEAD)"
Expand All @@ -337,7 +338,20 @@ jobs:
pr_scope="This PR updates generated dev-tool pin files to match the central version pins from [stranske/Workflows](https://github.com/stranske/Workflows)."
fi

# Create PR body
# Create PR body. Each generated PR is a leased delivery attempt;
# the durable campaign issue keeps the coordination history.
desired_tree_hash=${desired_tree:-$(git rev-parse 'HEAD^{tree}')}
lease_expires_at=$(date -u -d '+72 hours' +%Y-%m-%dT%H:%M:%SZ)
delivery_marker=$(jq -nc \
Comment thread
stranske marked this conversation as resolved.
--arg schema "sync-pr-delivery-record/v1" \
--arg durable_issue_url "https://github.com/stranske/Workflows/issues/1836" \
--arg plan_id "$DELIVERY_PLAN_ID" \
--arg generation "$DELIVERY_GENERATION" \
--arg repository "$DELIVERY_REPOSITORY" \
--arg desired_tree_hash "$desired_tree_hash" \
--arg source_commit "$GITHUB_SHA" \
--arg lease_expires_at "$lease_expires_at" \
'{schema:$schema,durable_issue_url:$durable_issue_url,plan_id:$plan_id,generation:$generation,repository:$repository,desired_tree_hash:$desired_tree_hash,source_commit:$source_commit,lease_expires_at:$lease_expires_at,predecessor_prs:[],successor_prs:[]}')
pr_body="## Dev Tool Version Sync

${pr_scope}
Expand All @@ -354,12 +368,19 @@ jobs:
- Easier debugging when tools behave the same everywhere

---
**Source:** \`.github/workflows/autofix-versions.env\`"
**Source:** \`.github/workflows/autofix-versions.env\`

<!-- sync-pr-delivery-record:v1 $delivery_marker -->"

gh pr create \
--head "$branch_name" \
--title "deps: sync dev tool versions" \
--body "$pr_body"
if [ -n "$existing_pr" ]; then
gh pr edit "$existing_pr" --body "$pr_body"
echo "Refreshed delivery lease for existing PR #$existing_pr"
else
gh pr create \
--head "$branch_name" \
--title "deps: sync dev tool versions" \
--body "$pr_body"
fi

- name: Dry run summary
if: >-
Expand Down
Loading
Loading