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
14 changes: 10 additions & 4 deletions .github/workflows/mvp-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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
44 changes: 44 additions & 0 deletions .github/workflows/planner-main-merge.yml
Original file line number Diff line number Diff line change
@@ -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'});
45 changes: 45 additions & 0 deletions .github/workflows/sync-asapplanner-main.yml
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions scripts/ci/README.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions scripts/ci/test_planner_merge.js
Original file line number Diff line number Diff line change
@@ -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; });
67 changes: 67 additions & 0 deletions scripts/ci/test_sync_planner.py
Original file line number Diff line number Diff line change
@@ -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()
35 changes: 35 additions & 0 deletions scripts/sync-asapplanner-main.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading