diff --git a/.github/workflows/mvp-ci.yml b/.github/workflows/mvp-ci.yml index 03ec24b2..8cfc1d22 100644 --- a/.github/workflows/mvp-ci.yml +++ b/.github/workflows/mvp-ci.yml @@ -49,6 +49,12 @@ jobs: # post-step can package `target`; registry/git inputs are sufficient. cache-targets: false + - name: Test Planner dependency updater + working-directory: ASAPQuery-backend + run: | + python3 -m unittest discover -s scripts/ci -p 'test_*.py' + node scripts/ci/test_planner_merge.js + - name: Check workspace formatting working-directory: ASAPQuery-backend # `--all` also follows this workspace's sibling path dependencies. @@ -59,20 +65,20 @@ jobs: env: CARGO_NET_GIT_FETCH_WITH_CLI: "true" working-directory: ASAPQuery-backend - run: cargo check --workspace + run: cargo check --workspace --locked - name: Lint workspace env: CARGO_NET_GIT_FETCH_WITH_CLI: "true" working-directory: ASAPQuery-backend # Preserve the existing warning policy while expanding runtime coverage. - run: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets --locked -- -D warnings - name: Build process-test control plane env: CARGO_NET_GIT_FETCH_WITH_CLI: "true" working-directory: ASAPQuery-backend - run: cargo build -p control_plane --bin control_plane + run: cargo build -p control_plane --bin control_plane --locked - name: Test workspace env: @@ -84,4 +90,4 @@ jobs: # Match scripts/e2e.sh to avoid competing flushers exhausting those waits. run: | export ASAP_E2E_CONTROL_PLANE_BIN="$PWD/target/debug/control_plane" - cargo test --workspace -- --test-threads=1 + cargo test --workspace --locked -- --test-threads=1 diff --git a/.github/workflows/planner-main-merge.yml b/.github/workflows/planner-main-merge.yml new file mode 100644 index 00000000..cbfdcb24 --- /dev/null +++ b/.github/workflows/planner-main-merge.yml @@ -0,0 +1,44 @@ +name: Merge verified Planner update + +on: + workflow_run: + workflows: ['MVP CI'] + types: [completed] + +permissions: + contents: write + pull-requests: write + +concurrency: + group: planner-main-merge + cancel-in-progress: false + +jobs: + merge: + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + steps: + # Never check out or execute PR content in this privileged workflow. + - uses: actions/github-script@v7 + with: + script: | + const {owner, repo} = context.repo; + const run = context.payload.workflow_run; + const prs = await github.rest.pulls.list({owner, repo, state: 'open', base: 'main', head: `${owner}:automation/sync-asapplanner-main`}); + if (prs.data.length !== 1) return; + const {data: pr} = await github.rest.pulls.get({owner, repo, pull_number: prs.data[0].number}); + if (pr.head.repo.full_name !== `${owner}/${repo}` || pr.head.sha !== run.head_sha || pr.draft) return; + const {data: comparison} = await github.rest.repos.compareCommitsWithBasehead({ + owner, repo, basehead: `main...${run.head_sha}` + }); + if (comparison.merge_base_commit.sha !== comparison.base_commit.sha) { + core.info('Main changed since this CI run; wait for the next updater refresh.'); + return; + } + const files = await github.paginate(github.rest.pulls.listFiles, {owner, repo, pull_number: pr.number}); + if (!files.length || files.some(f => !['Cargo.toml', 'Cargo.lock'].includes(f.filename))) { + core.setFailed('Planner automation may only change Cargo.toml and Cargo.lock'); + return; + } + // Required branch protection checks still apply; sha prevents merging a newer, untested head. + await github.rest.pulls.merge({owner, repo, pull_number: pr.number, sha: run.head_sha, merge_method: 'squash'}); diff --git a/.github/workflows/sync-asapplanner-main.yml b/.github/workflows/sync-asapplanner-main.yml new file mode 100644 index 00000000..29ffd235 --- /dev/null +++ b/.github/workflows/sync-asapplanner-main.yml @@ -0,0 +1,45 @@ +name: Sync ASAPPlanner main + +on: + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: sync-asapplanner-main + cancel-in-progress: true + +jobs: + update: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout backend + uses: actions/checkout@v4 + + - name: Configure private Cargo dependencies + env: + ASAP_CI_REPO_TOKEN: ${{ secrets.ASAP_CI_REPO_TOKEN }} + run: git config --global url."https://x-access-token:${ASAP_CI_REPO_TOKEN}@github.com/".insteadOf "https://github.com/" + + - name: Update Planner revision and lockfile + env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + run: ./scripts/sync-asapplanner-main.sh + + - name: Create or update synchronization PR + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.ASAP_CI_REPO_TOKEN }} + branch: automation/sync-asapplanner-main + delete-branch: true + commit-message: "chore: sync ASAPPlanner main" + title: "chore: sync ASAPPlanner main" + body: | + Updates all ASAPPlanner workspace dependencies to the current `main` commit and regenerates `Cargo.lock`. + + MVP CI validates formatting, compilation, linting and tests. A semantic Planner change stays visible as a failing synchronization PR until the backend adaptation is added. diff --git a/scripts/ci/README.md b/scripts/ci/README.md new file mode 100644 index 00000000..8c2d90e1 --- /dev/null +++ b/scripts/ci/README.md @@ -0,0 +1,31 @@ +# Planner main updates + +The backend keeps reproducible Planner revisions in `Cargo.toml` and `Cargo.lock`. +`Sync ASAPPlanner main` checks upstream main daily (or on manual dispatch), updates +all four Planner dependencies together, and opens or refreshes +`automation/sync-asapplanner-main` against backend main. GitHub scheduled runs can be delayed. + +The existing `ASAP_CI_REPO_TOKEN` secret must have read access to private dependency +repositories and **Contents and Pull requests write** access to this backend repo. +The updater uses this token to create its PR so GitHub triggers `MVP CI`. +The merge workflow uses the repository's `GITHUB_TOKEN` and does not require +GitHub's optional auto-merge feature to be enabled. + +MVP CI uses `--locked` and runs the workspace checks, Clippy, unit tests, and +process E2E tests. Only a successful run for the current update PR head can +trigger the merge workflow; it also verifies the repository, branch, base, and +that main has not advanced since that run and only the two Cargo files changed. Branch protection remains in force. +Failed updates stay open and main retains its last verified version. Fix an +upstream incompatibility separately, or let the next upstream update refresh the +PR. An automation failure is visible in Actions; check token permissions there. + +Locally, run `./scripts/sync-asapplanner-main.sh` from the repository root, +then run the same checks as MVP CI. Updater unit tests run with: + +```sh +python3 -m unittest discover -s scripts/ci -p 'test_*.py' +node scripts/ci/test_planner_merge.js +``` + +The sync script and scheduled workflow are shared with PR #765, at the same paths +and with the same automation branch; there is only one updater. diff --git a/scripts/ci/test_planner_merge.js b/scripts/ci/test_planner_merge.js new file mode 100644 index 00000000..1c99f4ac --- /dev/null +++ b/scripts/ci/test_planner_merge.js @@ -0,0 +1,44 @@ +// Execute the privileged workflow handler with mocked GitHub responses. +const fs = require('node:fs'); +const path = require('node:path'); +const workflow = fs.readFileSync(path.join(__dirname, '../../.github/workflows/planner-main-merge.yml'), 'utf8'); +const script = workflow.split(' script: |\n')[1].split('\n').map(line => line.slice(12)).join('\n'); +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; +const handler = new AsyncFunction('github', 'context', 'core', script); + +async function verifyMergeGuards() { + for (const scenario of ['valid', 'stale-head', 'foreign-repo', 'draft', 'stale-main', 'extra-file', 'empty-diff', 'no-pr']) { + let merged = false; + let failed = false; + const pr = { + number: 12, + head: {repo: {full_name: scenario === 'foreign-repo' ? 'else/repo' : 'o/r'}, sha: scenario === 'stale-head' ? 'old' : 'new'}, + draft: scenario === 'draft', + }; + const github = { + rest: { + pulls: { + list: async () => ({data: scenario === 'no-pr' ? [] : [pr]}), + get: async () => ({data: pr}), + listFiles: () => {}, + merge: async args => { + if (args.sha !== 'new' || args.pull_number !== 12) throw Error('Wrong merge target'); + merged = true; + }, + }, + repos: {compareCommitsWithBasehead: async () => ({data: { + merge_base_commit: {sha: scenario === 'stale-main' ? 'old' : 'base'}, + base_commit: {sha: 'base'}, + }})}, + }, + paginate: async () => scenario === 'empty-diff' ? [] : [{filename: scenario === 'extra-file' ? 'other.rs' : 'Cargo.toml'}], + }; + await handler(github, {repo: {owner: 'o', repo: 'r'}, payload: {workflow_run: {head_sha: 'new'}}}, { + info: () => {}, setFailed: () => { failed = true; }, + }); + if (merged !== (scenario === 'valid')) throw Error(`Unexpected merge: ${scenario}`); + if (failed !== ['extra-file', 'empty-diff'].includes(scenario)) throw Error(`Unexpected failure: ${scenario}`); + } +} + +verifyMergeGuards().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/scripts/ci/test_sync_planner.py b/scripts/ci/test_sync_planner.py new file mode 100644 index 00000000..cf06f427 --- /dev/null +++ b/scripts/ci/test_sync_planner.py @@ -0,0 +1,67 @@ +"""Exercise the shared sync script without accessing GitHub or Cargo registries.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).resolve().parents[1] / "sync-asapplanner-main.sh" + + +class PlannerSyncTests(unittest.TestCase): + revision = "a" * 40 + manifest = ''.join( + f'{name} = {{ git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "{"b" * 40}" }}\n' + for name in ['planner-types', 'asap-aware-mapping', 'asap-frontend-promql', 'asap-frontend-sql'] + ) + + def run_sync(self, manifest, revision=None, cargo_status=0): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + root = Path(temp.name) + (root / 'Cargo.toml').write_text(manifest) + (root / 'git').write_text('#!/bin/sh\nprintf "%s refs/heads/main\\n" "$TEST_REV"\n') + (root / 'cargo').write_text('#!/bin/sh\nprintf "%s\\n" "$@" > cargo-args\nexit "$TEST_CARGO_STATUS"\n') + for name in ['git', 'cargo']: + (root / name).chmod(0o755) + env = dict(os.environ, PATH=f'{root}:{os.environ["PATH"]}', + TEST_REV=self.revision if revision is None else revision, + TEST_CARGO_STATUS=str(cargo_status)) + result = subprocess.run(['bash', str(SCRIPT)], cwd=root, env=env, capture_output=True, text=True) + return root, result + + def test_updates_all_dependencies_and_lock_resolution(self): + """All Planner pins and Cargo's requested revision agree, preserving unrelated pins.""" + other = 'other = { git = "https://example.com/other", rev = "unchanged" }\n' + root, result = self.run_sync(self.manifest + other) + self.assertEqual(result.returncode, 0, result.stderr) + updated = (root / 'Cargo.toml').read_text() + self.assertEqual(updated.count(f'rev = "{self.revision}"'), 4) + self.assertTrue(updated.endswith(other)) + self.assertEqual((root / 'cargo-args').read_text().splitlines(), [ + 'update', '-p', 'asap-types@0.1.0', '-p', 'asap-aware-mapping', + '-p', 'asap-frontend-promql', '-p', 'asap-frontend-sql', '--precise', self.revision]) + + def test_invalid_revision_does_not_mutate_manifest(self): + """An upstream lookup failure cannot produce a malformed pin.""" + root, result = self.run_sync(self.manifest, revision='') + self.assertNotEqual(result.returncode, 0) + self.assertEqual((root / 'Cargo.toml').read_text(), self.manifest) + self.assertFalse((root / 'cargo-args').exists()) + + def test_unexpected_layout_does_not_partially_update(self): + """Removing a dependency requires deliberate updater changes.""" + manifest = self.manifest.split('\n', 1)[1] + root, result = self.run_sync(manifest) + self.assertNotEqual(result.returncode, 0) + self.assertEqual((root / 'Cargo.toml').read_text(), manifest) + self.assertFalse((root / 'cargo-args').exists()) + + def test_cargo_failure_is_reported(self): + """Lock resolution failure stops the workflow before PR publication.""" + _, result = self.run_sync(self.manifest, cargo_status=1) + self.assertNotEqual(result.returncode, 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/sync-asapplanner-main.sh b/scripts/sync-asapplanner-main.sh new file mode 100755 index 00000000..2c2a2d97 --- /dev/null +++ b/scripts/sync-asapplanner-main.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +planner_url="https://github.com/ProjectASAP/ASAPPlanner" +planner_rev="$(git ls-remote "$planner_url" refs/heads/main | awk 'NR == 1 { print $1 }')" +if [[ ! "$planner_rev" =~ ^[0-9a-f]{40}$ ]]; then + echo "could not resolve ASAPPlanner main to a commit" >&2 + exit 1 +fi + +python3 - "$planner_rev" <<'PY' +import pathlib +import re +import sys + +path = pathlib.Path("Cargo.toml") +text = path.read_text() +revision = sys.argv[1] +pattern = re.compile( + r'(git = "https://github.com/ProjectASAP/ASAPPlanner", rev = ")[0-9a-f]{40}("\s*})' +) +updated, replacements = pattern.subn(rf"\g<1>{revision}\2", text) +if replacements != 4: + raise SystemExit(f"expected four ASAPPlanner dependencies, found {replacements}") +path.write_text(updated) +PY + +cargo update \ + -p asap-types@0.1.0 \ + -p asap-aware-mapping \ + -p asap-frontend-promql \ + -p asap-frontend-sql \ + --precise "$planner_rev" + +echo "synchronized ASAPPlanner dependencies to $planner_rev"