diff --git a/.github/workflows/maint-50-tool-version-check.yml b/.github/workflows/maint-50-tool-version-check.yml index ebac697ab..2c8b5def3 100644 --- a/.github/workflows/maint-50-tool-version-check.yml +++ b/.github/workflows/maint-50-tool-version-check.yml @@ -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 }} @@ -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 diff --git a/.github/workflows/maint-52-sync-dev-versions.yml b/.github/workflows/maint-52-sync-dev-versions.yml index 8019b9694..4c79f0e4e 100644 --- a/.github/workflows/maint-52-sync-dev-versions.yml +++ b/.github/workflows/maint-52-sync-dev-versions.yml @@ -75,6 +75,7 @@ jobs: 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 @@ -104,6 +105,11 @@ jobs: 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: | @@ -275,6 +281,7 @@ jobs: DELIVERY_PLAN_ID: dev-tool-${{ needs.prepare.outputs.versions_hash }} DELIVERY_GENERATION: ${{ needs.prepare.outputs.versions_hash }} DELIVERY_REPOSITORY: ${{ matrix.repo }} + CANONICAL_SOURCE_SHA: ${{ needs.prepare.outputs.canonical_source_sha }} run: | cd consumer @@ -349,7 +356,7 @@ jobs: --arg generation "$DELIVERY_GENERATION" \ --arg repository "$DELIVERY_REPOSITORY" \ --arg desired_tree_hash "$desired_tree_hash" \ - --arg source_commit "$GITHUB_SHA" \ + --arg source_commit "$CANONICAL_SOURCE_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 @@ -369,6 +376,7 @@ jobs: --- **Source:** \`.github/workflows/autofix-versions.env\` + **Settled source commit:** \`$CANONICAL_SOURCE_SHA\` " @@ -406,15 +414,21 @@ jobs: if: always() steps: - name: Generate summary + env: + VERSIONS_HASH: ${{ needs.prepare.outputs.versions_hash }} + CANONICAL_SOURCE_SHA: ${{ needs.prepare.outputs.canonical_source_sha }} + DRY_RUN: ${{ inputs.dry_run || 'false' }} + REPOS_JSON: ${{ needs.prepare.outputs.repos }} run: | { echo "## Dev Version Sync Summary" echo "" - echo "**Versions Hash:** ${{ needs.prepare.outputs.versions_hash }}" - echo "**Dry Run:** ${{ inputs.dry_run || 'false' }}" + echo "**Versions Hash:** $VERSIONS_HASH" + echo "**Settled source commit:** $CANONICAL_SOURCE_SHA" + echo "**Dry Run:** $DRY_RUN" echo "" echo "### Repos Processed" - echo '${{ needs.prepare.outputs.repos }}' | jq -r '.repo[]' | while read -r repo; do + printf '%s\n' "$REPOS_JSON" | jq -r '.repo[]' | while read -r repo; do echo "- $repo" done } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/maint-auto-update-pypi-versions.yml b/.github/workflows/maint-auto-update-pypi-versions.yml index 2db7b8908..88a569a92 100644 --- a/.github/workflows/maint-auto-update-pypi-versions.yml +++ b/.github/workflows/maint-auto-update-pypi-versions.yml @@ -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. @@ -10,14 +10,18 @@ name: Maint Auto-Update PyPI Versions 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' 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 @@ -42,6 +46,19 @@ jobs: with: python-version: '3.14' + - name: Enforce the canonical source proposal policy + id: policy + env: + SECURITY_OVERRIDE: ${{ inputs.security_override }} + run: | + set -euo pipefail + args=() + if [ "$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" + - name: Check for outdated versions id: check run: | @@ -58,7 +75,10 @@ jobs: - 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 @@ -71,14 +91,20 @@ jobs: } >> "$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: | @@ -97,9 +123,19 @@ jobs: 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) + # A prior run can leave the weekly branch behind without an open PR. + # Capture its remote tip when present so the force-with-lease below + # is both retry-safe and able to refresh that branch. + git fetch origin "$branch:refs/remotes/origin/$branch" 2>/dev/null || true + expected_sha="" + if git show-ref --verify --quiet "refs/remotes/origin/$branch"; then + expected_sha=$(git rev-parse "refs/remotes/origin/$branch") + fi + git checkout -B "$branch" # Commit changes git add \ @@ -114,12 +150,14 @@ jobs: ${{ steps.update.outputs.summary }}" - # Push and create PR - git push origin "$branch" + # Push and create or refresh the one source PR for this window. + if [ -n "$expected_sha" ]; then + git push --force-with-lease="refs/heads/$branch:$expected_sha" -u origin "$branch" + else + git push --force-with-lease -u origin "$branch" + fi - 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. @@ -136,9 +174,17 @@ jobs: - 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 - name: Dry run summary if: inputs.dry_run == true diff --git a/docs/WORKFLOW_GUIDE.md b/docs/WORKFLOW_GUIDE.md index 28dd6a8b2..b1b14ca04 100644 --- a/docs/WORKFLOW_GUIDE.md +++ b/docs/WORKFLOW_GUIDE.md @@ -51,7 +51,7 @@ _Inline Gate helper_ - **`maint-45-cosmetic-repair.yml`** β€” Manual dispatch utility that runs `pytest -q`, applies guard-gated cosmetic fixes via `scripts/ci_cosmetic_repair.py`, and (when not in dry-run mode) opens a labelled PR with the default workflow tokenβ€”no extra GitHub App mint required. - **`maint-46-post-ci.yml`** β€” Post-CI recovery watcher triggered by `workflow_run` on Gate completion. It inspects the Gate summary job before touching the repo, and only checks out helpers / installs the token-balanced API client when the summary leg actually failed, keeping the default token pool free unless recovery is required. - **`maint-47-disable-legacy-workflows.yml`** β€” Manual dispatch utility to disable retired workflows that still appear in the Actions UI (with a dry-run preview + allowlist overrides); now relies solely on the default workflow token because the helper script never leaves the repository. -- **`maint-50-tool-version-check.yml`** β€” Weekly/manual tool-version audit that reads `autofix-versions.env`, hits PyPI to detect drifts, and files/refreshes the maintenance issue via the default token + load-balanced helper (no extra App mint). +- **`maint-50-tool-version-check.yml`** β€” Weekly/manual read-only tool-version audit that reports PyPI freshness for the canonical pin set; it never opens or comments on a competing update issue or PR. - **`maint-52-sync-dev-versions.yml`** β€” Fans out to each registered consumer repo (or a supplied subset), reports `autofix-versions.env` freshness for visibility, then exact-pins matching `pyproject.toml` entries and updates direct pins in `requirements.lock` and `requirements-dev.lock` when present; its wave ID covers both the pin set and propagation script so implementation repairs create replacement PRs. It uses the PAT provided via `REPO_TOKEN`, reuses `scripts/list_registered_consumer_repos.py`, and avoids redundant GitHub App token mints. - **`maint-52-validate-workflows.yml`** β€” PR/push workflow that dry-parses every workflow file with `yq`, runs actionlint with the repo allowlist, and caches both binaries; no extra GitHub App token is minted because the job never leaves the repository. - **`maint-60-release.yml`** β€” Tag-triggered release workflow that publishes notes with `softprops/action-gh-release` when a `v*` tag is pushed; only the default workflow token is needed, so no extra App mint runs. (Retains a legacy floating-`v1` tag step for any `v1.*` push, but consumers ride `@main` β€” the single supported pin β€” so the floating tag is no longer part of normal operation.) @@ -67,7 +67,7 @@ _Inline Gate helper_ - **`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). +- **`maint-auto-update-pypi-versions.yml`** β€” Monday 03:00 UTC canonical source lane that batches routine PyPI pin updates into one mutable source PR; an explicit security override may run outside that window. - **`maint-coverage-guard.yml`** β€” Scheduled coverage baseline monitor with a rate-limit gate that now relies solely on the shared API client + default token. - **`maint-coverage-guard.yml`** β€” Daily cron + dispatch workflow that monitors Gate coverage artifacts and maintains the rolling coverage baseline breach issue. - **`health-40-sweep.yml`** β€” Weekly sweep that fans out to Actionlint and branch-protection verification. Pull requests trigger the Actionlint leg (paths-filter gated) while schedule/manual runs execute both checks to keep the enforcement snapshots fresh. Manual dispatchers can now pass `run_branch_protection=false` to skip the API-heavy branch guard when they only need the workflow lint pass. diff --git a/docs/ci/TOOL_VERSION_MANAGEMENT.md b/docs/ci/TOOL_VERSION_MANAGEMENT.md index 18010cde7..3fe930069 100644 --- a/docs/ci/TOOL_VERSION_MANAGEMENT.md +++ b/docs/ci/TOOL_VERSION_MANAGEMENT.md @@ -66,27 +66,41 @@ 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 + +### 5. Canonical Source Proposal (`maint-auto-update-pypi-versions.yml`) +- Runs Mondays at 03:00 UTC, before consumer propagation +- 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 +### Operator Review 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**: +2. **Inspect the proposed pin set** when local reproduction is useful: ```bash # Edit .github/workflows/autofix-versions.env vim .github/workflows/autofix-versions.env @@ -109,28 +123,14 @@ When an update issue is created: mypy src tests ``` -4. **Create a PR**: - ```bash - git checkout -b chore/update-tool-versions - git add .github/workflows/autofix-versions.env - git commit -m "chore(ci): update tool versions - - - Black: X.X.X β†’ Y.Y.Y - - Ruff: X.X.X β†’ Y.Y.Y - - MyPy: X.X.X β†’ Y.Y.Y - - Addresses: issue #NNNN" - git push -u origin chore/update-tool-versions - ``` - -5. **Verify CI passes**: +4. **Verify CI passes** on that canonical source PR: - All Gate checks should pass - Autofix should use new versions if it runs - No formatting conflicts should occur -6. **Merge and close issue**: - - Merge the PR - - Close the version update issue +5. **Merge the canonical source PR**. Its settled commit is then the only + input to the Maint 52 consumer-propagation wave; do not create a parallel + update issue or competing source PR. ## Why Version Pinning? @@ -178,14 +178,14 @@ When an update issue is created: ### Weekly Check Not Running -**Symptom**: No version update issues being created +**Symptom**: No canonical source PR is being created when a routine update is due **Cause**: Workflow may be disabled or scheduled incorrectly **Solution**: -1. Check workflow is enabled in Actions UI -2. Verify cron schedule is correct (`0 8 * * 1`) -3. Manually trigger with workflow_dispatch to test +1. Check `maint-auto-update-pypi-versions.yml` is enabled in Actions UI +2. Verify the source-lane cron schedule is correct (`0 3 * * 1`, Mondays 03:00 UTC) +3. Manually trigger that workflow with workflow_dispatch to test ## Architecture Decisions @@ -241,6 +241,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 diff --git a/docs/ci/WORKFLOWS.md b/docs/ci/WORKFLOWS.md index 7f02a33d3..3da4a58ea 100644 --- a/docs/ci/WORKFLOWS.md +++ b/docs/ci/WORKFLOWS.md @@ -118,12 +118,12 @@ The gate uses the shared `.github/scripts/detect-changes.js` helper to decide wh * [`maint-45-cosmetic-repair.yml`](../../.github/workflows/maint-45-cosmetic-repair.yml) invokes the reusable autofix pipeline on a schedule to keep cosmetic issues in check. * [`maint-47-disable-legacy-workflows.yml`](../../.github/workflows/maint-47-disable-legacy-workflows.yml) sweeps the repository to make sure archived GitHub workflows remain disabled in the Actions UI. * [`maint-sync-action-versions.yml`](../../.github/workflows/maint-sync-action-versions.yml) syncs action version pins from `.github/workflows` into the workflow templates after Dependabot updates land. -* [`maint-50-tool-version-check.yml`](../../.github/workflows/maint-50-tool-version-check.yml) checks PyPI weekly for new versions of CI/autofix tools (black, ruff, mypy, pytest) and creates an issue when updates are available. +* [`maint-50-tool-version-check.yml`](../../.github/workflows/maint-50-tool-version-check.yml) checks PyPI weekly for new versions of CI/autofix tools (black, ruff, mypy, pytest) and publishes read-only freshness evidence; it never creates a competing issue or PR. * [`maint-39-test-llm-providers.yml`](../../.github/workflows/maint-39-test-llm-providers.yml) verifies LLM provider API keys (GitHub Models, OpenAI) are configured correctly for task completion analysis. * [`maint-sync-env-from-pyproject.yml`](../../.github/workflows/maint-sync-env-from-pyproject.yml) syncs `pyproject.toml`, templates, and direct `requirements.lock` pins from the canonical `autofix-versions.env` file after source pin changes land. * [`maint-52-validate-workflows.yml`](../../.github/workflows/maint-52-validate-workflows.yml) dry-parses every workflow with `yq`, runs `actionlint` with the repository allowlist, and fails fast when malformed YAML or unapproved actionlint findings slip in. -* [`maint-52-sync-dev-versions.yml`](../../.github/workflows/maint-52-sync-dev-versions.yml) syncs dev tool versions (ruff, mypy, black, isort, pytest, pytest-cov, pytest-xdist, hypothesis, coverage, and docformatter) from `autofix-versions.env` to consumer `pyproject.toml` files and direct pins in `requirements.lock` and `requirements-dev.lock` weekly or on version changes. -* [`maint-auto-update-pypi-versions.yml`](../../.github/workflows/maint-auto-update-pypi-versions.yml) checks PyPI daily for latest dev tool versions and creates a PR to update `autofix-versions.env` when versions are outdated. +* [`maint-52-sync-dev-versions.yml`](../../.github/workflows/maint-52-sync-dev-versions.yml) propagates already-validated canonical source pins (ruff, mypy, black, isort, pytest, pytest-cov, pytest-xdist, hypothesis, coverage, and docformatter) from the settled `autofix-versions.env` commit to consumer `pyproject.toml` files and direct pins in `requirements.lock` and `requirements-dev.lock`; it never independently proposes newer upstream versions. +* [`maint-auto-update-pypi-versions.yml`](../../.github/workflows/maint-auto-update-pypi-versions.yml) is the sole Monday source-proposal lane for `autofix-versions.env`, batching routine updates into one mutable PR and permitting an explicit security override outside the weekly window. * [`maint-62-integration-consumer.yml`](../../.github/workflows/maint-62-integration-consumer.yml) runs daily at 05:05 UTC, on release publication, or by manual dispatch to execute the integration-repo scenarios via the reusable Python CI template and keep the integration failure issue updated. * [`maint-65-sync-label-docs.yml`](../../.github/workflows/maint-65-sync-label-docs.yml) synchronizes `docs/LABELS.md` to consumer repositories weekly (Sundays 00:00 UTC) or via manual dispatch. * [`maint-66-monthly-audit.yml`](../../.github/workflows/maint-66-monthly-audit.yml) performs comprehensive monthly workflow health audits, collecting statistics and creating actionable tracking issues. diff --git a/docs/ci/WORKFLOW_SYSTEM.md b/docs/ci/WORKFLOW_SYSTEM.md index a93730aed..05cad8d5f 100644 --- a/docs/ci/WORKFLOW_SYSTEM.md +++ b/docs/ci/WORKFLOW_SYSTEM.md @@ -528,7 +528,7 @@ Keep this table handy when you are triaging automation: it confirms which workfl Dependabot updates land. - **Maint 50 Tool Version Check** – `.github/workflows/maint-50-tool-version-check.yml` runs weekly (Mondays 8:00 AM UTC) to check PyPI for new versions of CI/autofix tools - (black, ruff, mypy, pytest, etc.) and creates an issue when updates are available. + (black, ruff, mypy, pytest, etc.) and publishes read-only freshness evidence. - **Maint 39 Test LLM Providers** – `.github/workflows/maint-39-test-llm-providers.yml` is a manual workflow that verifies LLM provider API keys (GitHub Models, OpenAI) are configured correctly. Used to test the task completion analysis fallback chain. @@ -544,9 +544,9 @@ Keep this table handy when you are triaging automation: it confirms which workfl `autofix-versions.env` to consumer repository `pyproject.toml` files weekly or when version changes are detected. - **Maint Auto-Update PyPI Versions** – `.github/workflows/maint-auto-update-pypi-versions.yml` - checks PyPI daily (03:00 UTC) for latest dev tool versions and creates a PR - to update `autofix-versions.env` when versions are outdated, ensuring the - sync workflow never ships stale versions to consumer repos. + is the sole Monday 03:00 UTC source-proposal lane for routine dev-tool pins, + opening or refreshing one mutable PR and accepting an explicit security + override outside that window before consumer propagation. - **Maint 62 Integration Consumer** – `.github/workflows/maint-62-integration-consumer.yml` exercises the reusable Python CI template against the `templates/integration-repo` scenarios on a daily schedule (05:05Β UTC), on release publication, or via @@ -705,13 +705,13 @@ Keep this table handy when you are triaging automation: it confirms which workfl | **PR 11 - Minimal invariant CI** (`pr-11-ci-smoke.yml`, PR checks bucket) | `push` (`main`), `pull_request` (`main`), `workflow_dispatch` | Fast YAML + scripts syntax sanity check on Python 3.12 for early warning on workflow/script regressions. | βšͺ Automatic on push/PR | [Minimal invariant CI runs](https://github.com/stranske/Workflows/actions/workflows/pr-11-ci-smoke.yml) | | **PR 46 Dependency Repair Contract** (`pr-46-dependency-repair-contract.yml`, PR checks bucket) | `pull_request_target` for dependency-bot and marked promotion PRs | Keep bot branches classified and verify the bot-delta first commit before agent repair commits. | βœ… Required for dependency repair lanes | [Dependency repair contract runs](https://github.com/stranske/Workflows/actions/workflows/pr-46-dependency-repair-contract.yml) | | **Maint 47 Disable Legacy Workflows** (`maint-47-disable-legacy-workflows.yml`, maintenance bucket) | `workflow_dispatch` | Run `tools/disable_legacy_workflows.py` to disable archived workflows that still appear in Actions. | βšͺ Manual | [Maint 47 dispatch](https://github.com/stranske/Workflows/actions/workflows/maint-47-disable-legacy-workflows.yml) | -| **Maint 50 Tool Version Check** (`maint-50-tool-version-check.yml`, maintenance bucket) | `schedule` (Mondays 8:00 AM UTC), `workflow_dispatch` | Check PyPI for new versions of CI/autofix tools and create/update an issue when updates are available. | βšͺ Scheduled | [Maint 50 version checks](https://github.com/stranske/Workflows/actions/workflows/maint-50-tool-version-check.yml) | +| **Maint 50 Tool Version Check** (`maint-50-tool-version-check.yml`, maintenance bucket) | `schedule` (Mondays 8:00 AM UTC), `workflow_dispatch` | Publish read-only PyPI freshness evidence for the canonical dev-tool pin set. | βšͺ Scheduled | [Maint 50 version checks](https://github.com/stranske/Workflows/actions/workflows/maint-50-tool-version-check.yml) | | **Auto-label dependency PRs** (`maint-auto-label-dep-prs.yml`, maintenance bucket) | `pull_request_target` (`opened`) | Apply the `agents:allow-change` label to dependency-bot PRs (Dependabot + Renovate) so protected-workflow changes can be reviewed without manual label work. | βšͺ Automatic on PR open | [Auto-label runs](https://github.com/stranske/Workflows/actions/workflows/maint-auto-label-dep-prs.yml) | | **Auto-lock dependency PRs** (`maint-auto-lock-deps.yml`, maintenance bucket) | `pull_request` (dependency-bot branches `dependabot/**` + `renovate/**`, lock-input changes) | Regenerate `requirements.lock` by re-running the command recorded in the lock header when a dependency bot updates an input, committing the refreshed lock back to the PR branch. Workflows-local backstop to the fleet Renovate pip-compile manager. | βšͺ Automatic on dependency-bot PRs | [Auto-lock runs](https://github.com/stranske/Workflows/actions/workflows/maint-auto-lock-deps.yml) | | **Maint Sync pyproject.toml from versions.env** (`maint-sync-env-from-pyproject.yml`, maintenance bucket) | `push` (`main`, autofix pin/template/lock paths), `workflow_dispatch` | Sync `pyproject.toml`, templates, and direct `requirements.lock` pins from canonical `autofix-versions.env` after changes land. | βšͺ Automatic on main | [Maint sync env runs](https://github.com/stranske/Workflows/actions/workflows/maint-sync-env-from-pyproject.yml) | | **Maint 52 Validate Workflows** (`maint-52-validate-workflows.yml`, maintenance bucket) | `pull_request`, `push` (`main`) | Parse every workflow file with `yq`, honour the Actionlint allowlist, and fail fast when syntax errors or lint violations appear. | βšͺ Automatic on PR/main | [Maint 52 workflow validations](https://github.com/stranske/Workflows/actions/workflows/maint-52-validate-workflows.yml) | -| **Maint 52 Sync Dev Versions** (`maint-52-sync-dev-versions.yml`, maintenance bucket) | `schedule` (Sundays 01:00 UTC), `push` (`autofix-versions.env`), `workflow_dispatch` | Sync dev tool versions from `autofix-versions.env` to consumer repository `pyproject.toml` files. | βšͺ Scheduled/manual | [Sync dev versions runs](https://github.com/stranske/Workflows/actions/workflows/maint-52-sync-dev-versions.yml) | -| **Maint Auto-Update PyPI Versions** (`maint-auto-update-pypi-versions.yml`, maintenance bucket) | `schedule` (daily 03:00 UTC), `workflow_dispatch` | Check PyPI for latest dev tool versions and create a PR to update `autofix-versions.env` when versions are outdated. | βšͺ Scheduled | [Auto-update PyPI versions runs](https://github.com/stranske/Workflows/actions/workflows/maint-auto-update-pypi-versions.yml) | +| **Maint 52 Sync Dev Versions** (`maint-52-sync-dev-versions.yml`, maintenance bucket) | post-settled-source-commit `push` (`autofix-versions.env` on `main`), drift catch-up `schedule`, `workflow_dispatch` | Propagate already-validated canonical source pins from `autofix-versions.env` to consumer `pyproject.toml`/lock files; never independently proposes newer PyPI versions. | βšͺ Propagation after settled source | [Sync dev versions runs](https://github.com/stranske/Workflows/actions/workflows/maint-52-sync-dev-versions.yml) | +| **Maint Auto-Update PyPI Versions** (`maint-auto-update-pypi-versions.yml`, maintenance bucket) | `schedule` (Mondays 03:00 UTC), `workflow_dispatch` | Sole source proposal for routine dev-tool pins; opens or refreshes one weekly PR, with an explicit security override. | βšͺ Scheduled | [Auto-update PyPI versions runs](https://github.com/stranske/Workflows/actions/workflows/maint-auto-update-pypi-versions.yml) | | **Maint Coverage Guard** (`maint-coverage-guard.yml`, maintenance bucket) | `schedule` (`45 6 * * *`), `workflow_dispatch` | Audit the latest Gate coverage trend artifact and compare it against the baseline, failing when coverage regresses beyond the guard thresholds. | βšͺ Scheduled | [Maint Coverage Guard runs](https://github.com/stranske/Workflows/actions/workflows/maint-coverage-guard.yml) | | **Maint 46 Post CI** (`maint-46-post-ci.yml`, maintenance bucket) | `workflow_run` (Gate, `completed`) | Recovery-only: inspect the Gate run for a missing or failed `summary` job; when recovery is needed, collect the Gate artifacts, render the consolidated CI summary with coverage deltas, publish a markdown preview, and refresh the Gate commit status. Otherwise exit immediately. | βšͺ Automatic follow-up | [Maintβ€―46 runs](https://github.com/stranske/Workflows/actions/workflows/maint-46-post-ci.yml) | | **Maint 45 Cosmetic Repair** (`maint-45-cosmetic-repair.yml`, maintenance bucket) | `workflow_dispatch` | Run pytest + fixers manually and open a labelled PR when changes are required. | βšͺ Manual | [Maintβ€―45 manual entry](https://github.com/stranske/Workflows/actions/workflows/maint-45-cosmetic-repair.yml) | diff --git a/docs/ops/CONSUMER_REPO_MAINTENANCE.md b/docs/ops/CONSUMER_REPO_MAINTENANCE.md index 6feb7fc82..1368019d8 100644 --- a/docs/ops/CONSUMER_REPO_MAINTENANCE.md +++ b/docs/ops/CONSUMER_REPO_MAINTENANCE.md @@ -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 diff --git a/scripts/dev_tool_update_policy.py b/scripts/dev_tool_update_policy.py new file mode 100644 index 000000000..a16b45e52 --- /dev/null +++ b/scripts/dev_tool_update_policy.py @@ -0,0 +1,42 @@ +#!/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) + if args.security_override: + reason = "security_override" + elif allowed: + reason = "weekly_window" + else: + reason = "outside_weekly_window" + print(f"should_propose={'true' if allowed else 'false'}") + print(f"reason={reason}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/templates/consumer-repo/WORKFLOW_USER_GUIDE.md b/templates/consumer-repo/WORKFLOW_USER_GUIDE.md index b4ac0497c..befa207c8 100644 --- a/templates/consumer-repo/WORKFLOW_USER_GUIDE.md +++ b/templates/consumer-repo/WORKFLOW_USER_GUIDE.md @@ -781,17 +781,16 @@ The Workflows repository includes maintenance workflows that handle sync, update --- ### `maint-52-sync-dev-versions.yml` - Sync Dev Versions -**Purpose:** Updates development environment versions +**Purpose:** Central Workflows propagation workflow; it has no consumer-template counterpart. -**Trigger:** On push to main or manual +**Trigger:** Runs only in the central Workflows repository after a settled source commit **What It Does:** -- Syncs Python version from `.python-version` -- Updates Node.js version in workflows -- Updates action versions in workflows -- Commits version bumps +- Uses the central `autofix-versions.env` pin set +- Opens at most one propagation PR per consumer repository +- Records the settled canonical source commit in each delivery marker -**Use When:** After dependency updates +**Use When:** Observe central propagation; do not copy or configure this workflow in a consumer repo --- @@ -830,17 +829,16 @@ The Workflows repository includes maintenance workflows that handle sync, update --- ### `maint-auto-update-pypi-versions.yml` - Auto-Update PyPI Packages -**Purpose:** Automatically updates Python package versions +**Purpose:** Central Workflows source-proposal workflow; it has no consumer-template counterpart. -**Trigger:** Daily scheduled +**Trigger:** Monday 03:00 UTC or an explicit central security override **What It Does:** -- Checks PyPI for latest versions -- Updates minor/patch versions automatically -- Creates PR for major version updates -- Runs CI to validate +- Checks PyPI for the central dev-tool pin set +- Batches routine changes into one mutable weekly source PR +- Runs source validation before the consumer propagation lane can start -**Safety:** Only auto-merges patch versions +**Safety:** Consumer repos must not create partial copies of the central pin update --- @@ -1117,20 +1115,15 @@ The Workflows repository includes maintenance workflows that handle sync, update --- ### `maint-50-tool-version-check.yml` - Tool Version Audit -**Purpose:** Checks versions of all development tools +**Purpose:** Central read-only freshness audit; it has no consumer-template counterpart. -**Trigger:** Weekly scheduled +**Trigger:** Weekly in the central Workflows repository **Checks:** -- Python version -- Node.js version -- pip version -- git version -- gh version -- docker version -- Action versions - -**Result:** Report on outdated tools +- PyPI freshness for the central developer-tool pin set (`black`, `ruff`, `mypy`, `pytest`, related tooling) +- Canonical pin alignment evidence only (no runtime/CLI/Action version inventory) + +**Result:** Freshness evidence only; it never creates a competing update issue or PR --- diff --git a/tests/scripts/test_dev_tool_update_policy.py b/tests/scripts/test_dev_tool_update_policy.py new file mode 100644 index 000000000..0b20b6a33 --- /dev/null +++ b/tests/scripts/test_dev_tool_update_policy.py @@ -0,0 +1,48 @@ +from datetime import UTC, datetime + +import pytest +from scripts.dev_tool_update_policy import main, should_propose_update + + +def test_routine_updates_are_limited_to_the_weekly_utc_window(): + monday = datetime(2026, 8, 3, 3, 0, tzinfo=UTC) + tuesday = datetime(2026, 8, 4, 3, 0, tzinfo=UTC) + + assert should_propose_update(monday, security_override=False) + assert not should_propose_update(tuesday, security_override=False) + + +def test_policy_uses_the_utc_weekday_at_a_timezone_boundary(): + # Local Sunday evening in US/Eastern is already Monday UTC. + now = datetime.fromisoformat("2026-08-02T20:30:00-04:00") + + assert should_propose_update(now, security_override=False) is True + + +def test_security_override_bypasses_the_routine_window(): + tuesday = datetime(2026, 8, 4, 3, 0, tzinfo=UTC) + + assert should_propose_update(tuesday, security_override=True) + + +def test_policy_requires_an_aware_timestamp(): + with pytest.raises(ValueError, match="timezone-aware"): + should_propose_update(datetime(2026, 8, 3, 3, 0), security_override=False) + + +def test_cli_reports_the_weekly_window_output_contract(capsys): + assert main(["--at", "2026-08-03T03:00:00Z"]) == 0 + + assert capsys.readouterr().out == "should_propose=true\nreason=weekly_window\n" + + +def test_cli_reports_a_skipped_routine_window(capsys): + assert main(["--at", "2026-08-04T03:00:00Z"]) == 0 + + assert capsys.readouterr().out == "should_propose=false\nreason=outside_weekly_window\n" + + +def test_cli_reports_the_security_override_output_contract(capsys): + assert main(["--at", "2026-08-04T03:00:00Z", "--security-override"]) == 0 + + assert capsys.readouterr().out == "should_propose=true\nreason=security_override\n" diff --git a/tests/workflows/test_dev_tool_source_lane.py b/tests/workflows/test_dev_tool_source_lane.py new file mode 100644 index 000000000..ca1cabd7c --- /dev/null +++ b/tests/workflows/test_dev_tool_source_lane.py @@ -0,0 +1,34 @@ +from pathlib import Path + +AUTO_UPDATE = Path(".github/workflows/maint-auto-update-pypi-versions.yml") +MAINT50 = Path(".github/workflows/maint-50-tool-version-check.yml") +MAINT52 = Path(".github/workflows/maint-52-sync-dev-versions.yml") + + +def test_auto_updater_is_the_single_weekly_source_proposal_lane(): + text = AUTO_UPDATE.read_text(encoding="utf-8") + + assert "cron: '0 3 * * 1'" in text + assert 'scripts/dev_tool_update_policy.py "${args[@]}"' in text + assert "auto/weekly-dev-tool-update-$(date +%G-W%V)" in text + assert 'existing_pr=$(gh pr list --head "$branch"' in text + assert 'git fetch origin "$branch:refs/remotes/origin/$branch"' in text + assert 'git push --force-with-lease="refs/heads/$branch:$expected_sha"' in text + assert 'gh pr edit "$existing_pr"' in text + + +def test_maint50_reports_freshness_without_creating_competing_work(): + text = MAINT50.read_text(encoding="utf-8") + + assert "Create or update issue" not in text + assert "github.rest.issues.create" not in text + assert "github.rest.issues.createComment" not in text + assert "Source proposals belong to maint-auto-update-pypi-versions.yml" in text + + +def test_maint52_records_the_settled_canonical_source_commit(): + text = MAINT52.read_text(encoding="utf-8") + + assert "canonical_source_sha" in text + assert "git rev-parse HEAD" in text + assert "needs.prepare.outputs.canonical_source_sha" in text