Skip to content

Commit cd97750

Browse files
committed
ci: automatically track verified ASAPPlanner main revisions
1 parent 4112071 commit cd97750

8 files changed

Lines changed: 222 additions & 31 deletions

File tree

‎.github/workflows/mvp-ci.yml‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ jobs:
4949
# post-step can package `target`; registry/git inputs are sufficient.
5050
cache-targets: false
5151

52+
- name: Test Planner dependency updater
53+
working-directory: ASAPQuery-backend
54+
run: python3 -m unittest discover -s scripts/ci -p 'test_*.py'
55+
5256
- name: Check workspace formatting
5357
working-directory: ASAPQuery-backend
5458
# `--all` also follows this workspace's sibling path dependencies.
@@ -59,20 +63,20 @@ jobs:
5963
env:
6064
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
6165
working-directory: ASAPQuery-backend
62-
run: cargo check --workspace
66+
run: cargo check --workspace --locked
6367

6468
- name: Lint workspace
6569
env:
6670
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
6771
working-directory: ASAPQuery-backend
6872
# Preserve the existing warning policy while expanding runtime coverage.
69-
run: cargo clippy --workspace --all-targets -- -D warnings
73+
run: cargo clippy --workspace --all-targets --locked -- -D warnings
7074

7175
- name: Build process-test control plane
7276
env:
7377
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
7478
working-directory: ASAPQuery-backend
75-
run: cargo build -p control_plane --bin control_plane
79+
run: cargo build -p control_plane --bin control_plane --locked
7680

7781
- name: Test workspace
7882
env:
@@ -84,4 +88,4 @@ jobs:
8488
# Match scripts/e2e.sh to avoid competing flushers exhausting those waits.
8589
run: |
8690
export ASAP_E2E_CONTROL_PLANE_BIN="$PWD/target/debug/control_plane"
87-
cargo test --workspace -- --test-threads=1
91+
cargo test --workspace --locked -- --test-threads=1
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: Merge verified Planner update
2+
3+
on:
4+
workflow_run:
5+
workflows: ['MVP CI']
6+
types: [completed]
7+
8+
permissions:
9+
contents: write
10+
pull-requests: write
11+
12+
concurrency:
13+
group: planner-main-merge
14+
cancel-in-progress: false
15+
16+
jobs:
17+
merge:
18+
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
19+
runs-on: ubuntu-latest
20+
steps:
21+
# Never check out or execute PR content in this privileged workflow.
22+
- uses: actions/github-script@v7
23+
with:
24+
script: |
25+
const {owner, repo} = context.repo;
26+
const run = context.payload.workflow_run;
27+
const prs = await github.rest.pulls.list({owner, repo, state: 'open', base: 'main', head: `${owner}:automation/planner-main`});
28+
if (prs.data.length !== 1) return;
29+
const {data: pr} = await github.rest.pulls.get({owner, repo, pull_number: prs.data[0].number});
30+
if (pr.head.repo.full_name !== `${owner}/${repo}` || pr.head.sha !== run.head_sha || pr.draft) return;
31+
const testedPr = run.pull_requests.find(p => p.number === pr.number);
32+
if (!testedPr || testedPr.base.sha !== pr.base.sha) {
33+
core.info('Main changed since this CI run; wait for the next updater refresh.');
34+
return;
35+
}
36+
const files = await github.paginate(github.rest.pulls.listFiles, {owner, repo, pull_number: pr.number});
37+
if (!files.length || files.some(f => !['Cargo.toml', 'Cargo.lock'].includes(f.filename))) {
38+
core.setFailed('Planner automation may only change Cargo.toml and Cargo.lock');
39+
return;
40+
}
41+
// Required branch protection checks still apply; sha prevents merging a newer, untested head.
42+
await github.rest.pulls.merge({owner, repo, pull_number: pr.number, sha: run.head_sha, merge_method: 'squash'});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Sync Planner main
2+
3+
on:
4+
schedule:
5+
- cron: '17 * * * *'
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
11+
concurrency:
12+
group: planner-main-sync
13+
cancel-in-progress: false
14+
15+
jobs:
16+
update:
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 15
19+
steps:
20+
- uses: actions/checkout@v4
21+
with:
22+
ref: main
23+
- name: Configure private Cargo dependencies
24+
env:
25+
ASAP_CI_REPO_TOKEN: ${{ secrets.ASAP_CI_REPO_TOKEN }}
26+
run: git config --global url."https://x-access-token:${ASAP_CI_REPO_TOKEN}@github.com/".insteadOf "https://github.com/"
27+
- name: Resolve Planner main
28+
env:
29+
CARGO_NET_GIT_FETCH_WITH_CLI: 'true'
30+
run: python3 scripts/ci/update_planner.py
31+
# A repository token is needed so the resulting PR triggers MVP CI.
32+
# GITHUB_TOKEN-created PRs do not trigger pull_request workflows.
33+
- uses: peter-evans/create-pull-request@v7
34+
with:
35+
token: ${{ secrets.ASAP_CI_REPO_TOKEN }}
36+
branch: automation/planner-main
37+
base: main
38+
add-paths: |
39+
Cargo.toml
40+
Cargo.lock
41+
commit-message: 'build: sync ASAPPlanner main'
42+
title: 'build: sync ASAPPlanner main'
43+
body: |
44+
Before this PR: the backend pins an earlier ASAPPlanner revision.
45+
After this PR: all four Planner dependencies and Cargo.lock use the latest main revision observed by the updater.
46+
47+
MVP CI runs workspace formatting, checking, linting, and all workspace tests, including process E2E. Successful updates are merged automatically; incompatible updates remain open for investigation.

‎Cargo.lock‎

Lines changed: 14 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ asap_sketchlib = { git = "https://github.com/ProjectASAP/asap_sketchlib", branch
2020
[workspace.dependencies]
2121
# Keep Planner frontends, selection, and IR on the same immutable revision.
2222
# Alias upstream asap-types because this workspace also defines asap_types.
23-
planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "10d93846f0ef9f06ab19e599701d699d9cdd15af" }
24-
asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "10d93846f0ef9f06ab19e599701d699d9cdd15af" }
25-
asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "10d93846f0ef9f06ab19e599701d699d9cdd15af" }
26-
asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "10d93846f0ef9f06ab19e599701d699d9cdd15af" }
23+
planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2ec3fc80caa922c8e1f33aa05f60b7d787e73257" }
24+
asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2ec3fc80caa922c8e1f33aa05f60b7d787e73257" }
25+
asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2ec3fc80caa922c8e1f33aa05f60b7d787e73257" }
26+
asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2ec3fc80caa922c8e1f33aa05f60b7d787e73257" }
2727

2828
# Shared external deps (used by 2+ crates)
2929
serde = { version = "1.0", features = ["derive"] }

‎scripts/ci/README.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Planner main updates
2+
3+
The backend keeps reproducible Planner revisions in `Cargo.toml` and `Cargo.lock`.
4+
`Sync Planner main` checks upstream main hourly (or on manual dispatch), updates
5+
all four Planner dependencies together, and opens or refreshes
6+
`automation/planner-main` against backend main. GitHub scheduled runs can be delayed.
7+
8+
The existing `ASAP_CI_REPO_TOKEN` secret must have read access to private dependency
9+
repositories and **Contents and Pull requests write** access to this backend repo.
10+
The updater uses this token to create its PR so GitHub triggers `MVP CI`.
11+
The merge workflow uses the repository's `GITHUB_TOKEN` and does not require
12+
GitHub's optional auto-merge feature to be enabled.
13+
14+
MVP CI uses `--locked` and runs the workspace checks, Clippy, unit tests, and
15+
process E2E tests. Only a successful run for the current update PR head can
16+
trigger the merge workflow; it also verifies the repository, branch, base, and
17+
that main has not advanced since that run and only the two Cargo files changed. Branch protection remains in force.
18+
Failed updates stay open and main retains its last verified version. Fix an
19+
upstream incompatibility separately, or let the next upstream update refresh the
20+
PR. An automation failure is visible in Actions; check token permissions there.
21+
22+
Locally, run `python3 scripts/ci/update_planner.py` from the repository root,
23+
then run the same checks as MVP CI. Updater unit tests run with:
24+
25+
```sh
26+
python3 -m unittest discover -s scripts/ci -p 'test_*.py'
27+
```

‎scripts/ci/test_update_planner.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Guard atomic Planner revision updates and reject unexpected manifests."""
2+
import unittest
3+
4+
from update_planner import pin_planner
5+
6+
7+
class PlannerPinTests(unittest.TestCase):
8+
revision = "a" * 40
9+
manifest = ''.join(
10+
f'{name} = {{ git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "{"b" * 40}" }}\n'
11+
for name in ['planner-types', 'asap-aware-mapping', 'asap-frontend-promql', 'asap-frontend-sql']
12+
)
13+
14+
def test_atomic_and_idempotent(self):
15+
"""All four dependencies advance together, without touching other Git pins."""
16+
other = 'other = { git = "https://example.com/other", branch = "main" }\n'
17+
result = pin_planner(self.manifest + other, self.revision)
18+
self.assertEqual(result.count(f'rev = "{self.revision}"'), 4)
19+
self.assertTrue(result.endswith(other))
20+
self.assertEqual(pin_planner(result, self.revision), result)
21+
22+
def test_accepts_branch_selector(self):
23+
"""A branch-based Planner manifest can be converted to reproducible pins."""
24+
result = pin_planner(self.manifest.replace(f'rev = "{"b" * 40}"', 'branch = "main"'), self.revision)
25+
self.assertEqual(result.count(f'rev = "{self.revision}"'), 4)
26+
27+
def test_rejects_changed_dependency_layout(self):
28+
"""A removed dependency or missing selector requires a deliberate updater change."""
29+
for manifest in [self.manifest.split('\n', 1)[1], self.manifest.replace(f', rev = "{"b" * 40}"', '')]:
30+
with self.subTest(manifest=manifest), self.assertRaises(ValueError):
31+
pin_planner(manifest, self.revision)
32+
33+
def test_rejects_invalid_revision(self):
34+
"""Branch names and truncated SHAs cannot become immutable pins."""
35+
for revision in ['main', 'abc123', 'z' * 40]:
36+
with self.subTest(revision=revision), self.assertRaises(ValueError):
37+
pin_planner(self.manifest, revision)
38+
39+
40+
if __name__ == '__main__':
41+
unittest.main()

‎scripts/ci/update_planner.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/usr/bin/env python3
2+
"""Pin every Planner workspace dependency to one main revision, then resolve it."""
3+
import re
4+
import subprocess
5+
from pathlib import Path
6+
7+
URL = "https://github.com/ProjectASAP/ASAPPlanner"
8+
9+
10+
def pin_planner(manifest: str, revision: str) -> str:
11+
if not re.fullmatch(r"[0-9a-f]{40}", revision):
12+
raise ValueError("Expected a full Planner commit SHA")
13+
count = 0
14+
lines = []
15+
for line in manifest.splitlines(keepends=True):
16+
if re.search(r'git\s*=\s*"' + re.escape(URL) + r'"', line):
17+
line, replaced = re.subn(r'(?:rev|branch|tag)\s*=\s*"[^"]+"', f'rev = "{revision}"', line)
18+
if replaced != 1:
19+
raise ValueError("Planner dependency must have exactly one Git revision selector")
20+
count += 1
21+
lines.append(line)
22+
if count != 4:
23+
raise ValueError(f"Expected four Planner dependencies, found {count}; review the updater")
24+
return "".join(lines)
25+
26+
27+
if __name__ == "__main__":
28+
revision = subprocess.check_output(
29+
["git", "ls-remote", URL, "refs/heads/main"], text=True
30+
).split()[0]
31+
path = Path("Cargo.toml")
32+
original = path.read_text()
33+
updated = pin_planner(original, revision)
34+
if updated == original:
35+
print(f"Planner already pinned to main: {revision}")
36+
raise SystemExit(0)
37+
path.write_text(updated)
38+
subprocess.run(["cargo", "update", "-p", "asap-aware-mapping"], check=True)
39+
print(f"Planner main: {revision}")

0 commit comments

Comments
 (0)