Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
75 changes: 4 additions & 71 deletions .github/workflows/maint-50-tool-version-check.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
name: Maint 50 Tool Version Check

# Check for new versions of CI/autofix tools and create an issue if updates are available
# Runs weekly on Mondays at 8:00 AM UTC
# Read-only freshness evidence for CI/autofix tools.
# Source proposals belong to maint-auto-update-pypi-versions.yml, which owns
# the weekly batch window and any explicit security override.

on:
schedule:
- cron: '0 8 * * 1' # Weekly on Mondays
workflow_dispatch:
inputs:
force_issue:
description: 'Force issue creation even if versions are current'
required: false
default: 'false'

permissions:
contents: read
issues: write

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand Down Expand Up @@ -85,71 +80,9 @@ jobs:
run: |
python scripts/maint_tool_version_check.py compare

- name: Create or update issue
if: steps.compare.outputs.has_updates == 'true' || github.event.inputs.force_issue == 'true'
uses: actions/github-script@v9
with:
github-token: ${{ github.token }}
script: |
const fs = require('fs');
const retryHelperPath = './.github/scripts/github-api-with-retry.js';
const retryHelpers = fs.existsSync(retryHelperPath)
? require(retryHelperPath)
: {
withRetry: (fn) => fn(),
paginateWithRetry: (githubInstance, method, params) =>
githubInstance.paginate(method, params),
};
const { withRetry } = retryHelpers;
const body = fs.readFileSync('tool-version-issue-body.md', 'utf8');

const title = '🔧 CI/Autofix Tool Updates Available';

// Search for existing open issues with this title
const issues = await withRetry(() =>
github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'maintenance,dependencies',
per_page: 100,
})
);

const existingIssue = issues.data.find(issue => issue.title === title);

if (existingIssue) {
// Update existing issue
await withRetry(() => github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingIssue.number,
body: body
}));

await withRetry(() => github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingIssue.number,
body: `🔄 Updated with latest version check results.`
}));

core.notice(`Updated existing issue #${existingIssue.number}`);
} else {
// Create new issue
const issue = await withRetry(() => github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: title,
body: body,
labels: ['maintenance', 'dependencies']
}));

core.notice(`Created issue #${issue.data.number}`);
}

- name: Summary
run: |
echo "Source proposals belong to maint-auto-update-pypi-versions.yml." >> "$GITHUB_STEP_SUMMARY"
cat <<'EOF' >> "$GITHUB_STEP_SUMMARY"
${{ steps.compare.outputs.report }}
EOF
10 changes: 9 additions & 1 deletion .github/workflows/maint-52-sync-dev-versions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
outputs:
repos: ${{ steps.repos.outputs.matrix }}
versions_hash: ${{ steps.hash.outputs.hash }}
canonical_source_sha: ${{ steps.source.outputs.sha }}
steps:
- name: Checkout Workflows
uses: actions/checkout@v7
Expand Down Expand Up @@ -104,6 +105,11 @@
echo "Current versions:"
grep -v '^#' .github/workflows/autofix-versions.env | grep '='

- name: Record settled canonical source commit
id: source
run: |
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

- name: Build repo matrix
id: repos
run: |
Expand Down Expand Up @@ -349,7 +355,7 @@
--arg generation "$DELIVERY_GENERATION" \
--arg repository "$DELIVERY_REPOSITORY" \
--arg desired_tree_hash "$desired_tree_hash" \
--arg source_commit "$GITHUB_SHA" \
--arg source_commit "${{ needs.prepare.outputs.canonical_source_sha }}" \
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
--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
Expand All @@ -369,6 +375,7 @@

---
**Source:** \`.github/workflows/autofix-versions.env\`
**Settled source commit:** \`${{ needs.prepare.outputs.canonical_source_sha }}\`
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

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

Expand Down Expand Up @@ -411,6 +418,7 @@
echo "## Dev Version Sync Summary"
echo ""
echo "**Versions Hash:** ${{ needs.prepare.outputs.versions_hash }}"
echo "**Settled source commit:** ${{ needs.prepare.outputs.canonical_source_sha }}"
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
echo "**Dry Run:** ${{ inputs.dry_run || 'false' }}"
echo ""
echo "### Repos Processed"
Expand Down
69 changes: 52 additions & 17 deletions .github/workflows/maint-auto-update-pypi-versions.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Auto-update dev tool versions from PyPI
#
# This workflow ensures autofix-versions.env stays current with PyPI releases.
# It runs daily and creates a PR if any versions are outdated.
# Routine proposals run once each Monday and create at most one source PR.
#
# CRITICAL: This workflow MUST run before maint-52-sync-dev-versions.yml
# to ensure we never ship stale versions to consumer repos.
Expand All @@ -10,14 +10,18 @@

on:
schedule:
# Daily at 03:00 UTC (before the weekly sync at 05:00)
- cron: '0 3 * * *'
# Monday 03:00 UTC, before the consumer propagation window.
- cron: '0 3 * * 1'
Comment thread
stranske marked this conversation as resolved.
Resolved
workflow_dispatch:
inputs:
dry_run:
description: 'Preview changes without creating PR'
type: boolean
default: false
security_override:
description: 'Allow a reviewed security update outside the routine weekly window'
type: boolean
default: false

permissions:
contents: write
Expand All @@ -42,6 +46,17 @@
with:
python-version: '3.14'

- name: Enforce the canonical source proposal policy
id: policy
run: |
set -euo pipefail
args=()
if [ "${{ inputs.security_override }}" = "true" ]; then
args+=(--security-override)
fi
python scripts/dev_tool_update_policy.py "${args[@]}" | tee /tmp/source_policy.txt
cat /tmp/source_policy.txt >> "$GITHUB_OUTPUT"

Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
- name: Check for outdated versions
id: check
run: |
Expand All @@ -58,7 +73,10 @@

- name: Update versions
id: update
if: steps.check.outputs.has_updates == 'true' && inputs.dry_run != true
if: >-
steps.check.outputs.has_updates == 'true'
&& steps.policy.outputs.should_propose == 'true'
&& inputs.dry_run != true
run: |
echo "📦 Updating autofix-versions.env with latest PyPI versions..."
python scripts/update_versions_from_pypi.py --apply 2>&1 | tee /tmp/update_output.txt
Expand All @@ -71,14 +89,20 @@
} >> "$GITHUB_OUTPUT"

- name: Sync pyproject, lockfile, and templates
if: steps.check.outputs.has_updates == 'true' && inputs.dry_run != true
if: >-
steps.check.outputs.has_updates == 'true'
&& steps.policy.outputs.should_propose == 'true'
&& inputs.dry_run != true
run: |
echo "🔄 Syncing pyproject.toml + requirements.lock + templates"
python scripts/sync_tool_versions.py --apply
python scripts/sync_dev_dependencies.py --apply --lockfile

- name: Create PR
if: steps.check.outputs.has_updates == 'true' && inputs.dry_run != true
if: >-
steps.check.outputs.has_updates == 'true'
&& steps.policy.outputs.should_propose == 'true'
&& inputs.dry_run != true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
Expand All @@ -97,9 +121,14 @@
exit 0
fi

# Create branch
branch="auto/update-pypi-versions-$(date +%Y%m%d)"
git checkout -b "$branch"
# One mutable source PR per weekly window. A security override uses
# the same branch if the routine window already has a proposal.
branch="auto/weekly-dev-tool-update-$(date +%G-W%V)"
existing_pr=$(gh pr list --head "$branch" --state open --json number -q '.[0].number' || true)
if [ -n "$existing_pr" ]; then
git fetch origin "$branch"
Comment thread
stranske marked this conversation as resolved.
Resolved
fi
git checkout -B "$branch"
Comment thread
stranske marked this conversation as resolved.
Resolved

Comment thread
stranske marked this conversation as resolved.
Resolved
# Commit changes
git add \
Expand All @@ -114,12 +143,10 @@

${{ steps.update.outputs.summary }}"

# Push and create PR
git push origin "$branch"
# Push and create or refresh the one source PR for this window.
git push --force-with-lease -u origin "$branch"

gh pr create \
--title "chore: update dev tool versions from PyPI" \
--body "## Summary
pr_body="## Summary

This PR updates the pinned dev tool versions in \`autofix-versions.env\`.
It matches the latest releases on PyPI.
Expand All @@ -136,9 +163,17 @@
- Version drift between repos is minimized

---
*Auto-generated by the maint-auto-update-pypi-versions workflow*" \
--label "dependencies" \
--label "automation"
*Auto-generated by the maint-auto-update-pypi-versions workflow*"

if [ -n "$existing_pr" ]; then
gh pr edit "$existing_pr" --title "chore: update dev tool versions from PyPI" --body "$pr_body"
else
gh pr create \
--title "chore: update dev tool versions from PyPI" \
--body "$pr_body" \
--label "dependencies" \
--label "automation"
fi
Comment thread
stranske marked this conversation as resolved.
Resolved

- name: Dry run summary
if: inputs.dry_run == true
Expand Down
40 changes: 28 additions & 12 deletions docs/ci/TOOL_VERSION_MANAGEMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,25 +66,39 @@ COVERAGE_VERSION=7.12.0
### 4. Version Check (`maint-50-tool-version-check.yml`)
- Runs weekly on Mondays at 8:00 AM UTC
- Checks PyPI for latest versions of all tools
- Creates/updates issue when updates are available
- Manual dispatch available with `force_issue` option
- Publishes read-only freshness evidence; it never opens or comments on a
competing update issue or PR
Comment thread
stranske marked this conversation as resolved.
Resolved

### 5. Canonical Source Proposal (`maint-auto-update-pypi-versions.yml`)
- Runs Mondays at 03:00 UTC, before consumer propagation
Comment thread
stranske marked this conversation as resolved.
Resolved
- Is the only routine workflow allowed to open or refresh a Workflows dev-tool
source PR
- Uses one mutable `auto/weekly-dev-tool-update-YYYY-Www` PR per weekly window
- An operator may use the explicit `security_override` dispatch input for an
urgent security update outside that window

## Update Process

### Automated Monitoring

The `maint-50-tool-version-check.yml` workflow automatically:
1. Checks PyPI weekly for new tool versions
2. Compares with current pinned versions
3. Creates an issue titled "🔧 CI/Autofix Tool Updates Available"
4. Lists all available updates in the issue
5. Updates the issue if already exists (doesn't spam with duplicates)
The source lane is deliberately single-writer:

1. `maint-50-tool-version-check.yml` reports PyPI freshness only.
2. `maint-auto-update-pypi-versions.yml` checks the canonical pin file in the
Monday batch window and opens or refreshes one source PR for all routine
updates found together.
3. After that source PR merges and its normal validation succeeds, the
`maint-52-sync-dev-versions.yml` push trigger propagates the exact settled
source commit to consumers. Its delivery marker and PR body record that SHA.
4. A security-sensitive update may be manually dispatched with
`security_override=true`; it remains on the same source lane and still runs
the normal source validation before propagation.

### Manual Update Steps

When an update issue is created:
When the canonical source lane opens a PR:

1. **Review the update issue** to see which tools have new versions
1. **Review the canonical source PR** to see which tools have new versions

2. **Update the version file**:
```bash
Expand Down Expand Up @@ -241,6 +255,8 @@ drift. Full ownership table:

## Maintenance Schedule

- **Weekly**: Automated version check (Mondays 8:00 AM UTC)
- **As Needed**: Manual updates when security issues arise
- **Weekly**: One source proposal window (Mondays 03:00 UTC) and read-only
freshness report (Mondays 08:00 UTC)
- **As Needed**: Explicit `security_override` source-lane dispatch for reviewed
security updates
- **Quarterly**: Review and update this documentation
7 changes: 5 additions & 2 deletions docs/ops/CONSUMER_REPO_MAINTENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,8 +325,11 @@ path instead. Runtime dependency bumps remain normal Dependabot work.
Consumer alignment must not wait for unrelated PyPI freshness. The
`maint-52-sync-dev-versions.yml` workflow reports whether newer PyPI versions
exist, but continues syncing the canonical pins from Workflows. The
`maint-auto-update-pypi-versions.yml` workflow owns opening source bump PRs for
freshness updates.
`maint-auto-update-pypi-versions.yml` workflow is the sole source-proposal lane:
it batches routine updates into one Monday UTC PR and accepts a reviewed manual
security override when needed. Maint 50 reports freshness but never creates a
competing issue or proposal. Each consumer wave records the settled Workflows
source commit so propagation can be traced back to the validated source change.

### Renovate vs Maint 68 Path Ownership

Expand Down
37 changes: 37 additions & 0 deletions scripts/dev_tool_update_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Decide whether the canonical dev-tool source lane may propose an update.

Routine updates are batched into one Monday UTC window. A security-sensitive
update may bypass that window only when an operator explicitly requests it.
"""

from __future__ import annotations

import argparse
from datetime import UTC, datetime

ROUTINE_WINDOW_WEEKDAY = 0 # Monday in ``datetime.weekday`` notation.


def should_propose_update(now: datetime, *, security_override: bool) -> bool:
"""Return whether the canonical source lane may open or refresh its PR."""
if now.tzinfo is None:
raise ValueError("now must be timezone-aware")
return security_override or now.astimezone(UTC).weekday() == ROUTINE_WINDOW_WEEKDAY


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--security-override", action="store_true")
parser.add_argument("--at", help="UTC ISO-8601 timestamp for deterministic checks")
args = parser.parse_args(argv)
now = datetime.fromisoformat(args.at.replace("Z", "+00:00")) if args.at else datetime.now(UTC)
allowed = should_propose_update(now, security_override=args.security_override)
reason = "security_override" if args.security_override else "weekly_window"
print(f"should_propose={'true' if allowed else 'false'}")
print(f"reason={reason}")
Comment thread
stranske marked this conversation as resolved.
Resolved
return 0

Comment thread
stranske marked this conversation as resolved.
Resolved

if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading