From 2edf4784c2d1be439e95ae0d66822b0aaca6511e Mon Sep 17 00:00:00 2001 From: kikashy Date: Thu, 22 Oct 2026 13:42:08 -0400 Subject: [PATCH 1/5] Dispatch CI after automated branch updates Use an optional update token and explicitly dispatch CI when the workflow falls back to GITHUB_TOKEN so checks run on the updated pull request head. Vendor the handover check beside the check runner and keep both copies held to their sources and release version. Co-Authored-By: Codex Signed-off-by: kikashy --- .github/workflows/release.yml | 10 +++++++--- README.md | 9 +++++---- bin/jpdev | 3 +++ docs/adopting.md | 35 +++++++++++++++++++++++++---------- docs/releasing.md | 7 +++++-- templates/ci-check.yml | 34 +++++++++++++++++++++++++++++----- templates/update-branches.yml | 22 ++++++++++++++++++++++ tests/run.sh | 19 +++++++++++++++++++ 8 files changed, 115 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77bccb2..2093a7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -141,8 +141,8 @@ jobs: ref: ${{ github.sha }} # The kit's version is the tag without its v, or the job refuses: the tarball's name, the - # version `jpdev --version` prints and the version line of the runner template - # (templates/check.sh, line 2) must agree with the tag. + # version `jpdev --version` prints and the version lines of the vendored scripts must agree + # with the tag. - name: Require the kit's version to equal the tag id: version shell: bash @@ -154,6 +154,10 @@ jobs: want="# from judgment-pack-devkit ${RELEASE_TAG} (templates/check.sh)" [ "${line}" = "${want}" ] || { echo "templates/check.sh line 2 is '${line}'; the tag ${RELEASE_TAG} needs '${want}'" >&2; exit 1; } cmp -s templates/check.sh scripts/dev/check.sh || { echo "scripts/dev/check.sh is not a copy of templates/check.sh" >&2; exit 1; } + line="$(sed -n 2p scripts/dev/handover_check.sh)" + want="# from judgment-pack-devkit ${RELEASE_TAG} (lib/handover_check.sh)" + [ "${line}" = "${want}" ] || { echo "scripts/dev/handover_check.sh line 2 is '${line}'; the tag ${RELEASE_TAG} needs '${want}'" >&2; exit 1; } + cmp -s <(tail -n +2 lib/handover_check.sh) <(tail -n +3 scripts/dev/handover_check.sh) || { echo "scripts/dev/handover_check.sh is not a versioned copy of lib/handover_check.sh" >&2; exit 1; } echo "version=${version}" >> "${GITHUB_OUTPUT}" # git archive writes the named paths of the commit, with the commit's time and each file's @@ -184,7 +188,7 @@ jobs: kit="${unpack}/judgment-pack-devkit-${VERSION}" [ "$("${kit}/bin/jpdev" --version)" = "jpdev ${VERSION}" ] || { echo "the packaged jpdev does not report ${VERSION}" >&2; exit 1; } "${kit}/bin/jpdev" env > /dev/null - for path in bin/jpdev lib/wrap.sh templates/check.sh templates/dev.schema.json scripts/sync-agent-docs.sh examples/judgment-pack-agent.dev.json LICENSE NOTICE README.md; do + for path in bin/jpdev lib/wrap.sh lib/handover_check.sh templates/check.sh templates/dev.schema.json scripts/dev/handover_check.sh scripts/sync-agent-docs.sh examples/judgment-pack-agent.dev.json LICENSE NOTICE README.md; do test -e "${kit}/${path}" || { echo "the tarball does not hold ${path}" >&2; exit 1; } done diff --git a/README.md b/README.md index 45ca87d..12517d7 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,8 @@ script uses the environment's interpreter and packages. - `config/env.example`, `config/codex-words.txt`: the settings by example, and the base word list of `jpdev codex check-brief`. - `templates/dev.json`, `templates/dev.schema.json`: the manifest by example and its JSON Schema. -- `templates/check.sh`: the public runner a repository vendors as `scripts/dev/check.sh`. +- `templates/check.sh` and `lib/handover_check.sh`: the public checks a repository vendors as + `scripts/dev/check.sh` and `scripts/dev/handover_check.sh`. - `templates/codex-brief.md`: the brief skeleton `jpdev codex brief-template` prints. - `templates/AGENTS.md`, `templates/CLAUDE.md`: the agent instructions (one text, generated from the other by `scripts/sync-agent-docs.sh`). @@ -171,8 +172,8 @@ script uses the environment's interpreter and packages. repository adopts the kit. `examples/judgment-pack-agent.dev.json`: a worked manifest. - `tests/run.sh`: the kit's own tests (`tests/fixture/` holds a manifest of shell echoes and one deliberate failure). -- `dev.json`, `scripts/dev/check.sh`, `AGENTS.md`, `CLAUDE.md`: the kit on itself, the list of checks - CI runs. `CONTRIBUTING.md`, `SECURITY.md`, `docs/releasing.md`: contributing, reporting and - releasing. +- `dev.json`, `scripts/dev/check.sh`, `scripts/dev/handover_check.sh`, `AGENTS.md`, `CLAUDE.md`: the + kit on itself, the list of checks CI runs, and the handover guard. `CONTRIBUTING.md`, `SECURITY.md`, + `docs/releasing.md`: contributing, reporting and releasing. Apache-2.0; see `LICENSE`. diff --git a/bin/jpdev b/bin/jpdev index 363d653..27a82db 100755 --- a/bin/jpdev +++ b/bin/jpdev @@ -138,6 +138,9 @@ cmd_setup() { py_v=$("$py" --version 2>&1) fi local script=$root/scripts/dev/check.sh + if [ ! -x "$script" ] || [ ! -x "$root/scripts/dev/handover_check.sh" ]; then + jpdev_log "setup: adopting repositories vendor scripts/dev/check.sh and scripts/dev/handover_check.sh together" + fi [ -x "$script" ] || script=$kit/templates/check.sh JPDEV_LOGDIR=$JPDEV_SCRATCH/logs/$(basename "$root")-setup-$(date +%Y%m%dT%H%M%S) export JPDEV_LOGDIR diff --git a/docs/adopting.md b/docs/adopting.md index 16ccb0c..5578af2 100644 --- a/docs/adopting.md +++ b/docs/adopting.md @@ -1,8 +1,8 @@ # Adopting the kit in a repository -A repository adopts the kit with five files and one CI workflow. None of them depends on the -kit being installed: a contributor or CI runs the vendored runner with python3 alone, and a -machine that has the kit adds the caps and the lock around the same steps. +A repository adopts the kit with five files and two workflows. None of them depends on the kit +being installed: a contributor or CI runs the two vendored scripts directly, and a machine that +has the kit adds the caps and the lock around the same steps. ## 1. Write `dev.json` @@ -41,16 +41,21 @@ in the summary block; more than three lines are counted and the last one is show `jpdev check` validates the file against `templates/dev.schema.json` before it runs anything. -## 2. Vendor the runner +## 2. Vendor the check scripts ```sh mkdir -p scripts/dev -cp templates/check.sh scripts/dev/check.sh +devkit=path/to/judgment-pack-devkit +cp "$devkit/templates/check.sh" scripts/dev/check.sh +cp "$devkit/scripts/dev/handover_check.sh" scripts/dev/handover_check.sh ``` Line 2 of the template, `# from judgment-pack-devkit vX.Y.Z (templates/check.sh)`, records the kit release the copy came from. A vendored copy keeps that line, so a later reader can tell which release -it is behind; when you update the copy, copy the whole file again. +it is behind. The kit's `scripts/dev/handover_check.sh` similarly vendors `lib/handover_check.sh` +with its release on line 2. Vendor `scripts/dev/check.sh` and `scripts/dev/handover_check.sh` +together, and copy both whole files again when updating them. `jpdev setup` names this pair when +either file is absent. `scripts/dev/check.sh [--setup] [--only A,B] [--skip A,B] [--all]` runs the manifest with no caps and prints the summary block. `jpdev check` uses the vendored copy when it exists and @@ -74,13 +79,23 @@ regenerates `CLAUDE.md`. ## 4. Add the CI workflow -Copy `templates/ci-check.yml` to `.github/workflows/dev-check.yml`. It reads the toolchain +Copy `templates/ci-check.yml` to `.github/workflows/ci.yml`. It reads the toolchain versions from `dev.json`, sets the toolchains up, and runs `scripts/dev/check.sh --setup` and `scripts/dev/check.sh --all`. Its handover job rejects removals from boundary paths unless the pull -request body contains `Boundary change: yes`, then runs `handover.schema_probe` when present. Copy +request body contains `Boundary change: yes`, then runs `handover.schema_probe` when present through +the vendored `scripts/dev/handover_check.sh`. Copy `templates/update-branches.yml` to `.github/workflows/update-branches.yml` to update open branches -after a default-branch push and label conflict cases `carry needed`. A repository that already has -CI jobs keeps them and uses the workflow as the list of steps to move its checks into. +after a default-branch push and label conflict cases `carry needed`. + +Set the optional `UPDATE_BRANCHES_TOKEN` secret to a fine-grained personal access token or GitHub +App installation token with repository contents and pull-requests write access. An update made with +that token starts CI through the resulting push. When the secret is absent, the workflow uses its +`GITHUB_TOKEN` and explicitly dispatches `ci.yml` for the updated pull-request branch, passing the +pull-request number so the `handover` job can read its base and body. The workflow's `actions: write` +permission permits that fallback dispatch. After adopting the workflows, check that an automatically +updated pull request shows the `check` and `handover` CI checks on its new head. A repository that +already has CI jobs keeps their required names and uses the template as the list of steps to move +into its `ci.yml`. ## 5. Check it diff --git a/docs/releasing.md b/docs/releasing.md index af99632..ad8e05f 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -44,7 +44,10 @@ the second. 1. Open a pull request that: - sets `JPDEV_VERSION` in `lib/common.sh` to the release's version, and line 2 of `templates/check.sh` to `# from judgment-pack-devkit v (templates/check.sh)`, and - copies the template to `scripts/dev/check.sh` (a test holds the three together); + copies the template to `scripts/dev/check.sh`; sets line 2 of + `scripts/dev/handover_check.sh` to + `# from judgment-pack-devkit v (lib/handover_check.sh)` and copies the rest from + `lib/handover_check.sh` (tests hold both vendored scripts to their sources and version); - writes `docs/releases/.md`: what the release holds and what it does not. It is the text the release page carries, and the first line ends with the tag's name, for example `# jpdev v0.2.0`. @@ -85,7 +88,7 @@ Every job works on the commit the run was started for, by its digest, not on the [`ci.yml`](../.github/workflows/ci.yml): `lint` and `tests`. A check `main` has gained since that commit is not run. 3. **Packages the kit.** It refuses unless `bin/jpdev --version` prints `jpdev `, line 2 of - `templates/check.sh` names ``, and `scripts/dev/check.sh` is a copy of the template. Then + each vendored script names ``, and both scripts match their sources. Then `git archive` of the commit into `judgment-pack-devkit-.tgz`, `checksums.txt` written over it and checked, and the unpacked tarball run once (`jpdev --version`, `jpdev env`). 4. **Attests and drafts.** Only if the tag still names the commit and no release exists under the diff --git a/templates/ci-check.yml b/templates/ci-check.yml index 64482da..206728a 100644 --- a/templates/ci-check.yml +++ b/templates/ci-check.yml @@ -1,4 +1,4 @@ -# Vendor as .github/workflows/dev-check.yml. Runs the repository's own check list (dev.json) +# Vendor as .github/workflows/ci.yml. Runs the repository's own check list (dev.json) # with scripts/dev/check.sh, with the toolchains set up from the manifest and no caps. name: dev-check @@ -6,10 +6,17 @@ on: push: branches: [main] pull_request: + workflow_dispatch: + inputs: + pull_request: + description: Pull request whose updated head is being checked + required: true + type: string workflow_call: permissions: contents: read + pull-requests: read jobs: check: @@ -74,17 +81,34 @@ jobs: handover: name: handover - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: contents: read + pull-requests: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false + - name: Read dispatched pull request metadata + if: github.event_name == 'workflow_dispatch' + id: pull + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const pullNumber = Number(context.payload.inputs.pull_request); + if (!Number.isSafeInteger(pullNumber) || pullNumber < 1) { + core.setFailed('pull_request must be a positive integer'); + return; + } + const pull = await github.rest.pulls.get({ + owner: context.repo.owner, repo: context.repo.repo, pull_number: pullNumber, + }); + core.setOutput('base', pull.data.base.sha); + core.setOutput('body', pull.data.body || ''); - name: Check boundary paths and run the schema probe env: - JPDEV_HANDOVER_BASE: ${{ github.event.pull_request.base.sha }} - JPDEV_PR_BODY: ${{ github.event.pull_request.body }} - run: lib/handover_check.sh + JPDEV_HANDOVER_BASE: ${{ github.event.pull_request.base.sha || steps.pull.outputs.base }} + JPDEV_PR_BODY: ${{ github.event.pull_request.body || steps.pull.outputs.body }} + run: scripts/dev/handover_check.sh diff --git a/templates/update-branches.yml b/templates/update-branches.yml index 96dcefd..278c776 100644 --- a/templates/update-branches.yml +++ b/templates/update-branches.yml @@ -7,6 +7,7 @@ on: workflow_call: permissions: + actions: write contents: write pull-requests: write issues: write @@ -18,7 +19,10 @@ jobs: steps: - name: Update branches that are behind uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + USING_GITHUB_TOKEN: ${{ secrets.UPDATE_BRANCHES_TOKEN == '' }} with: + github-token: ${{ secrets.UPDATE_BRANCHES_TOKEN || secrets.GITHUB_TOKEN }} script: | const owner = context.repo.owner; const repo = context.repo.repo; @@ -35,6 +39,24 @@ jobs: await github.rest.pulls.updateBranch({ owner, repo, pull_number: pull.number, expected_head_sha: pull.head.sha, }); + if (process.env.USING_GITHUB_TOKEN === 'true') { + let updated = pull; + for (let attempt = 0; attempt < 30; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + const response = await github.rest.pulls.get({ + owner, repo, pull_number: pull.number, + }); + updated = response.data; + if (updated.head.sha !== pull.head.sha) break; + } + if (updated.head.sha === pull.head.sha) { + throw new Error(`Pull request ${pull.number} did not expose its updated head`); + } + await github.rest.actions.createWorkflowDispatch({ + owner, repo, workflow_id: 'ci.yml', ref: updated.head.ref, + inputs: { pull_request: String(pull.number) }, + }); + } try { await github.rest.issues.removeLabel({ owner, repo, issue_number: pull.number, name: 'carry needed', diff --git a/tests/run.sh b/tests/run.sh index c025b2d..38da999 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -263,6 +263,11 @@ want="# from judgment-pack-devkit v$("$jpdev" --version | awk '{ print $2 }') (t if [ "$line2" = "$want" ]; then ok "templates/check.sh line 2 names the kit version jpdev --version prints"; else bad "templates/check.sh line 2 names the kit version jpdev --version prints" "line 2: $line2 wanted: $want"; fi if cmp -s "$kit/templates/check.sh" "$kit/scripts/dev/check.sh"; then ok "scripts/dev/check.sh is the template, line 2 included"; else bad "scripts/dev/check.sh is the template, line 2 included"; fi +handover_line2=$(sed -n 2p "$kit/scripts/dev/handover_check.sh" 2>/dev/null) +handover_want="# from judgment-pack-devkit v$("$jpdev" --version | awk '{ print $2 }') (lib/handover_check.sh)" +if [ "$handover_line2" = "$handover_want" ]; then ok "scripts/dev/handover_check.sh line 2 names the kit version and source"; else bad "scripts/dev/handover_check.sh line 2 names the kit version and source" "line 2: $handover_line2 +wanted: $handover_want"; fi +if cmp -s <(tail -n +2 "$kit/lib/handover_check.sh") <(tail -n +3 "$kit/scripts/dev/handover_check.sh" 2>/dev/null); then ok "scripts/dev/handover_check.sh is the library script after its version line"; else bad "scripts/dev/handover_check.sh is the library script after its version line"; fi # --- the lock lock=$work/test.lock @@ -319,6 +324,17 @@ PY ) && s=0 || s=$? expect "the workflow templates parse as YAML" 0 "parsed workflow templates" "$out" "$s" fi +workflow_templates=$(cat "$kit/templates/ci-check.yml" "$kit/templates/update-branches.yml") +expect "the branch updater prefers UPDATE_BRANCHES_TOKEN and falls back to GITHUB_TOKEN" 0 'github-token: \$\{\{ secrets\.UPDATE_BRANCHES_TOKEN \|\| secrets\.GITHUB_TOKEN \}\}' "$workflow_templates" 0 +expect "the GITHUB_TOKEN fallback dispatches ci.yml for the pull request branch" 0 "createWorkflowDispatch" "$workflow_templates" 0 +expect "the branch updater dispatches CI only for the GITHUB_TOKEN fallback" 0 "process\.env\.USING_GITHUB_TOKEN === 'true'" "$workflow_templates" 0 +expect "the branch updater waits until the pull request exposes its new head" 0 "updated\.head\.sha !== pull\.head\.sha" "$workflow_templates" 0 +expect "the fallback dispatch targets the updated branch head" 0 "workflow_id: 'ci\.yml', ref: updated\.head\.ref" "$workflow_templates" 0 +expect "the fallback dispatch identifies the pull request" 0 "pull_request: String\(pull\.number\)" "$workflow_templates" 0 +expect "the CI template accepts an explicit dispatch for a pull request" 0 "workflow_dispatch:" "$workflow_templates" 0 +expect "the dispatched CI keeps the required check job name" 0 '^ name: check$' "$workflow_templates" 0 +expect "the dispatched CI keeps the required handover job name" 0 '^ name: handover$' "$workflow_templates" 0 +expect "the CI handover job calls the vendored handover check" 0 'run: scripts/dev/handover_check\.sh' "$workflow_templates" 0 handover_repo=$work/handover-check git init -q -b main "$handover_repo" @@ -929,10 +945,13 @@ git -C "$seed" -c user.name=test -c user.email=test@example.com commit -q -m see git -C "$seed" remote add origin "$origin" git -C "$seed" push -q origin main git clone -q "$origin" "$work/clone" +mkdir -p "$work/clone/scripts/dev" +cp "$kit/templates/check.sh" "$work/clone/scripts/dev/check.sh" out=$("$jpdev" setup "$work/clone" --repo Example/repo --branch topic --base origin/main 2>&1) && s=0 || s=$? expect "setup makes the branch from the base" 0 'branch +topic at' "$out" "$s" expect "setup runs the manifest's setup steps" 0 'PASS +prepare' "$out" "$s" expect "setup names the toolchains" 0 'python +Python' "$out" "$s" +expect "setup names both scripts in its adoption guidance" 0 'vendor scripts/dev/check\.sh and scripts/dev/handover_check\.sh together' "$out" "$s" if [ "$(git -C "$work/clone" rev-parse --abbrev-ref HEAD)" = topic ]; then ok "setup leaves the clone on the new branch"; else bad "setup leaves the clone on the new branch"; fi out=$("$jpdev" setup "$work/clone" --branch topic 2>&1) && s=0 || s=$? expect "setup reuses an existing clone and an existing branch" 0 'branch +topic' "$out" "$s" From ecfac01853d8a22313241b6cd780bdccfbf5eb5a Mon Sep 17 00:00:00 2001 From: kikashy Date: Thu, 22 Oct 2026 13:42:34 -0400 Subject: [PATCH 2/5] Add the vendored handover_check.sh named by the build (#21) Signed-off-by: kikashy --- scripts/dev/handover_check.sh | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100755 scripts/dev/handover_check.sh diff --git a/scripts/dev/handover_check.sh b/scripts/dev/handover_check.sh new file mode 100755 index 0000000..41fa9aa --- /dev/null +++ b/scripts/dev/handover_check.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# from judgment-pack-devkit v0.5.0 (lib/handover_check.sh) +set -eu + +python3 - <<'PY' +import json, os, subprocess, sys + +manifest = json.load(open("dev.json", encoding="utf-8")) +handover = manifest.get("handover", {}) +base = os.environ["JPDEV_HANDOVER_BASE"] +if "Boundary change: yes" not in os.environ.get("JPDEV_PR_BODY", "").splitlines(): + for path in handover.get("boundary_paths", []): + diff = subprocess.check_output( + ["git", "diff", "--unified=0", base + "...HEAD", "--", path], text=True + ) + removed = [line for line in diff.splitlines() if line.startswith("-") and not line.startswith("---")] + if removed: + print("handover: boundary path has removals: " + path, file=sys.stderr) + raise SystemExit(1) +probe = handover.get("schema_probe") +if probe: + subprocess.run(["bash", "-c", probe], check=True) +PY From 934c17abf782d2315c4acd1dd14a2293c3ac6367 Mon Sep 17 00:00:00 2001 From: kikashy Date: Thu, 22 Oct 2026 13:56:39 -0400 Subject: [PATCH 3/5] Handle fork and dispatch failures in branch updates Refuse fallback dispatches for fork pull requests with a visible comment and failure. Limit conflict handling to updateBranch so dispatch errors propagate, and verify the dispatched base-repository ref against the new head. Cover both review findings with mocked API vectors. Signed-off-by: kikashy --- docs/adopting.md | 4 +++ templates/update-branches.yml | 53 ++++++++++++++++++----------------- tests/run.sh | 22 +++++++++++++++ 3 files changed, 54 insertions(+), 25 deletions(-) diff --git a/docs/adopting.md b/docs/adopting.md index 5578af2..f6e889a 100644 --- a/docs/adopting.md +++ b/docs/adopting.md @@ -97,6 +97,10 @@ updated pull request shows the `check` and `handover` CI checks on its new head. already has CI jobs keeps their required names and uses the template as the list of steps to move into its `ci.yml`. +The fallback cannot dispatch a fork's short branch name in the base repository. For an updated fork +pull request it comments that checks must be started by a push from the fork, then fails visibly; +the contributor must push from the fork to start the checks on that head. + ## 5. Check it ```sh diff --git a/templates/update-branches.yml b/templates/update-branches.yml index 278c776..3fd415f 100644 --- a/templates/update-branches.yml +++ b/templates/update-branches.yml @@ -39,31 +39,6 @@ jobs: await github.rest.pulls.updateBranch({ owner, repo, pull_number: pull.number, expected_head_sha: pull.head.sha, }); - if (process.env.USING_GITHUB_TOKEN === 'true') { - let updated = pull; - for (let attempt = 0; attempt < 30; attempt += 1) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - const response = await github.rest.pulls.get({ - owner, repo, pull_number: pull.number, - }); - updated = response.data; - if (updated.head.sha !== pull.head.sha) break; - } - if (updated.head.sha === pull.head.sha) { - throw new Error(`Pull request ${pull.number} did not expose its updated head`); - } - await github.rest.actions.createWorkflowDispatch({ - owner, repo, workflow_id: 'ci.yml', ref: updated.head.ref, - inputs: { pull_request: String(pull.number) }, - }); - } - try { - await github.rest.issues.removeLabel({ - owner, repo, issue_number: pull.number, name: 'carry needed', - }); - } catch (error) { - if (error.status !== 404) throw error; - } } catch (error) { if (error.status !== 409 && error.status !== 422) throw error; await github.rest.issues.addLabels({ @@ -73,5 +48,33 @@ jobs: owner, repo, issue_number: pull.number, body: `Carry needed from base commit ${baseCommit}.`, }); + continue; + } + if (process.env.USING_GITHUB_TOKEN === 'true') { + let updated = pull; + for (let attempt = 0; attempt < 30; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + updated = (await github.rest.pulls.get({owner, repo, pull_number: pull.number})).data; + if (updated.head.sha !== pull.head.sha) break; + } + if (updated.head.sha === pull.head.sha) throw new Error(`Pull request ${pull.number} did not expose its updated head`); + if (!updated.head.repo || updated.head.repo.full_name !== `${owner}/${repo}`) { + const message = 'Checks must be started by a push from the fork.'; + await github.rest.issues.createComment({owner, repo, issue_number: pull.number, body: message}); + throw new Error(message); + } + await github.rest.actions.createWorkflowDispatch({ + owner, repo, workflow_id: 'ci.yml', ref: updated.head.ref, + inputs: { pull_request: String(pull.number) }, + }); + const dispatched = await github.rest.git.getRef({owner, repo, ref: `heads/${updated.head.ref}`}); + if (dispatched.data.object.sha !== updated.head.sha) throw new Error(`Dispatched ref does not name pull request ${pull.number} head ${updated.head.sha}`); + } + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: pull.number, name: 'carry needed', + }); + } catch (error) { + if (error.status !== 404) throw error; } } diff --git a/tests/run.sh b/tests/run.sh index 38da999..120a4b5 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -335,6 +335,28 @@ expect "the CI template accepts an explicit dispatch for a pull request" 0 "work expect "the dispatched CI keeps the required check job name" 0 '^ name: check$' "$workflow_templates" 0 expect "the dispatched CI keeps the required handover job name" 0 '^ name: handover$' "$workflow_templates" 0 expect "the CI handover job calls the vendored handover check" 0 'run: scripts/dev/handover_check\.sh' "$workflow_templates" 0 +if command -v node > /dev/null; then + out=$(node - "$kit/templates/update-branches.yml" <<'JS' +const yaml = require('fs').readFileSync(process.argv[2], 'utf8'), body = yaml.slice(yaml.indexOf(' script: |') + 20).split('\n').map(x => x.slice(12)).join('\n'), execute = Function('github', 'context', 'process', 'setTimeout', `return (async()=>{${body}})()`), pull = {number: 7, head: {sha: 'old', ref: 'topic', repo: {full_name: 'base/repo'}}}; +async function vector(headRepo, dispatchError) { + const calls = {dispatch: 0, labels: 0, comments: []}, updated = {...pull, head: {...pull.head, sha: 'new', repo: {full_name: headRepo}}}; + const rest = {pulls: {list() {}, async updateBranch() {}, async get() { return {data: updated}; }}, repos: {async compareCommitsWithBasehead() { return {data: {status: 'ahead'}}; }}, + actions: {async createWorkflowDispatch() { calls.dispatch++; if (dispatchError) throw dispatchError; }}, git: {async getRef() { return {data: {object: {sha: 'new'}}}; }}, + issues: {async removeLabel() {}, async addLabels() { calls.labels++; }, async createComment(x) { calls.comments.push(x.body); }}}; + let error; try { await execute({rest, paginate: async () => [pull]}, {repo: {owner: 'base', repo: 'repo'}, sha: 'base', payload: {repository: {default_branch: 'main'}}}, {env: {USING_GITHUB_TOKEN: 'true'}}, resolve => resolve()); } catch (caught) { error = caught; } + return {calls, error}; +} +(async () => { + const fork = await vector('fork/repo'); + if (!fork.error || fork.calls.dispatch || fork.calls.labels || fork.calls.comments[0] !== 'Checks must be started by a push from the fork.') throw Error('fork vector failed'); + const failure = Object.assign(Error('dispatch refused'), {status: 422}), dispatch = await vector('base/repo', failure); + if (dispatch.error !== failure || dispatch.calls.labels || dispatch.calls.comments.length) throw Error('dispatch vector failed'); + console.log('workflow update mocks passed'); +})().catch(error => { console.error(error); process.exit(1); }); +JS + ) && s=0 || s=$? + expect "the branch updater handles fork and dispatch-failure API vectors" 0 '^workflow update mocks passed$' "$out" "$s" +fi handover_repo=$work/handover-check git init -q -b main "$handover_repo" From b440b850fb25e104a6d6de785e663da733b6cd87 Mon Sep 17 00:00:00 2001 From: kikashy Date: Thu, 22 Oct 2026 14:04:15 -0400 Subject: [PATCH 4/5] Pin dispatched CI to the updated head SHA Pass the updated pull request head SHA as a workflow dispatch input. Name that SHA in the CI run and use it for both checkouts so a stale branch ref cannot start checks on another commit. Extend the workflow mock to reject ref lookup and non-SHA dispatch behavior. Co-Authored-By: Codex Signed-off-by: kikashy --- docs/adopting.md | 3 ++- templates/ci-check.yml | 11 +++++++++++ templates/update-branches.yml | 4 +--- tests/run.sh | 12 +++++++++--- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/docs/adopting.md b/docs/adopting.md index f6e889a..dc1129c 100644 --- a/docs/adopting.md +++ b/docs/adopting.md @@ -95,7 +95,8 @@ pull-request number so the `handover` job can read its base and body. The workfl permission permits that fallback dispatch. After adopting the workflows, check that an automatically updated pull request shows the `check` and `handover` CI checks on its new head. A repository that already has CI jobs keeps their required names and uses the template as the list of steps to move -into its `ci.yml`. +into its `ci.yml`. The dispatch passes the updated head SHA, which `ci.yml` names in the run title +and checks out for both jobs. The fallback cannot dispatch a fork's short branch name in the base repository. For an updated fork pull request it comments that checks must be started by a push from the fork, then fails visibly; diff --git a/templates/ci-check.yml b/templates/ci-check.yml index 206728a..1780c4a 100644 --- a/templates/ci-check.yml +++ b/templates/ci-check.yml @@ -1,6 +1,7 @@ # Vendor as .github/workflows/ci.yml. Runs the repository's own check list (dev.json) # with scripts/dev/check.sh, with the toolchains set up from the manifest and no caps. name: dev-check +run-name: dev-check ${{ inputs.sha || github.sha }} on: push: @@ -8,11 +9,19 @@ on: pull_request: workflow_dispatch: inputs: + sha: + description: Updated pull request head to check + required: true + type: string pull_request: description: Pull request whose updated head is being checked required: true type: string workflow_call: + inputs: + sha: + required: false + type: string permissions: contents: read @@ -26,6 +35,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ inputs.sha }} - name: Read the toolchains from dev.json id: dev run: | @@ -91,6 +101,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false + ref: ${{ inputs.sha }} - name: Read dispatched pull request metadata if: github.event_name == 'workflow_dispatch' id: pull diff --git a/templates/update-branches.yml b/templates/update-branches.yml index 3fd415f..8941a28 100644 --- a/templates/update-branches.yml +++ b/templates/update-branches.yml @@ -65,10 +65,8 @@ jobs: } await github.rest.actions.createWorkflowDispatch({ owner, repo, workflow_id: 'ci.yml', ref: updated.head.ref, - inputs: { pull_request: String(pull.number) }, + inputs: { pull_request: String(pull.number), sha: updated.head.sha }, }); - const dispatched = await github.rest.git.getRef({owner, repo, ref: `heads/${updated.head.ref}`}); - if (dispatched.data.object.sha !== updated.head.sha) throw new Error(`Dispatched ref does not name pull request ${pull.number} head ${updated.head.sha}`); } try { await github.rest.issues.removeLabel({ diff --git a/tests/run.sh b/tests/run.sh index 120a4b5..75813de 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -331,7 +331,11 @@ expect "the branch updater dispatches CI only for the GITHUB_TOKEN fallback" 0 " expect "the branch updater waits until the pull request exposes its new head" 0 "updated\.head\.sha !== pull\.head\.sha" "$workflow_templates" 0 expect "the fallback dispatch targets the updated branch head" 0 "workflow_id: 'ci\.yml', ref: updated\.head\.ref" "$workflow_templates" 0 expect "the fallback dispatch identifies the pull request" 0 "pull_request: String\(pull\.number\)" "$workflow_templates" 0 +expect "the fallback dispatch carries the updated head SHA" 0 "sha: updated\.head\.sha" "$workflow_templates" 0 expect "the CI template accepts an explicit dispatch for a pull request" 0 "workflow_dispatch:" "$workflow_templates" 0 +expect "the dispatched CI names its SHA input in the run title" 0 'run-name: .*inputs\.sha' "$workflow_templates" 0 +sha_checkouts=$(grep -cF 'ref: ${{ inputs.sha }}' "$kit/templates/ci-check.yml") +if [ "$sha_checkouts" -eq 2 ]; then ok "both dispatched CI jobs check out the SHA input"; else bad "both dispatched CI jobs check out the SHA input" "found $sha_checkouts SHA checkouts"; fi expect "the dispatched CI keeps the required check job name" 0 '^ name: check$' "$workflow_templates" 0 expect "the dispatched CI keeps the required handover job name" 0 '^ name: handover$' "$workflow_templates" 0 expect "the CI handover job calls the vendored handover check" 0 'run: scripts/dev/handover_check\.sh' "$workflow_templates" 0 @@ -339,16 +343,18 @@ if command -v node > /dev/null; then out=$(node - "$kit/templates/update-branches.yml" <<'JS' const yaml = require('fs').readFileSync(process.argv[2], 'utf8'), body = yaml.slice(yaml.indexOf(' script: |') + 20).split('\n').map(x => x.slice(12)).join('\n'), execute = Function('github', 'context', 'process', 'setTimeout', `return (async()=>{${body}})()`), pull = {number: 7, head: {sha: 'old', ref: 'topic', repo: {full_name: 'base/repo'}}}; async function vector(headRepo, dispatchError) { - const calls = {dispatch: 0, labels: 0, comments: []}, updated = {...pull, head: {...pull.head, sha: 'new', repo: {full_name: headRepo}}}; + const calls = {dispatch: [], refs: 0, labels: 0, comments: []}, updated = {...pull, head: {...pull.head, sha: 'new', repo: {full_name: headRepo}}}; const rest = {pulls: {list() {}, async updateBranch() {}, async get() { return {data: updated}; }}, repos: {async compareCommitsWithBasehead() { return {data: {status: 'ahead'}}; }}, - actions: {async createWorkflowDispatch() { calls.dispatch++; if (dispatchError) throw dispatchError; }}, git: {async getRef() { return {data: {object: {sha: 'new'}}}; }}, + actions: {async createWorkflowDispatch(x) { calls.dispatch.push(x.inputs); if (dispatchError) throw dispatchError; }}, git: {async getRef() { calls.refs++; return {data: {object: {sha: 'new'}}}; }}, issues: {async removeLabel() {}, async addLabels() { calls.labels++; }, async createComment(x) { calls.comments.push(x.body); }}}; let error; try { await execute({rest, paginate: async () => [pull]}, {repo: {owner: 'base', repo: 'repo'}, sha: 'base', payload: {repository: {default_branch: 'main'}}}, {env: {USING_GITHUB_TOKEN: 'true'}}, resolve => resolve()); } catch (caught) { error = caught; } return {calls, error}; } (async () => { const fork = await vector('fork/repo'); - if (!fork.error || fork.calls.dispatch || fork.calls.labels || fork.calls.comments[0] !== 'Checks must be started by a push from the fork.') throw Error('fork vector failed'); + if (!fork.error || fork.calls.dispatch.length || fork.calls.labels || fork.calls.comments[0] !== 'Checks must be started by a push from the fork.') throw Error('fork vector failed'); + const success = await vector('base/repo'); + if (success.error || success.calls.dispatch[0].sha !== 'new' || success.calls.refs) throw Error('SHA dispatch vector failed'); const failure = Object.assign(Error('dispatch refused'), {status: 422}), dispatch = await vector('base/repo', failure); if (dispatch.error !== failure || dispatch.calls.labels || dispatch.calls.comments.length) throw Error('dispatch vector failed'); console.log('workflow update mocks passed'); From 0edfffbf0591ba88e97d1ebdc0620c9d97f0e4b1 Mon Sep 17 00:00:00 2001 From: kikashy Date: Thu, 22 Oct 2026 14:07:03 -0400 Subject: [PATCH 5/5] Silence SC2016 on the literal workflow expression the test greps for Signed-off-by: kikashy --- tests/run.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/run.sh b/tests/run.sh index 75813de..ae6f8bb 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -334,6 +334,7 @@ expect "the fallback dispatch identifies the pull request" 0 "pull_request: Stri expect "the fallback dispatch carries the updated head SHA" 0 "sha: updated\.head\.sha" "$workflow_templates" 0 expect "the CI template accepts an explicit dispatch for a pull request" 0 "workflow_dispatch:" "$workflow_templates" 0 expect "the dispatched CI names its SHA input in the run title" 0 'run-name: .*inputs\.sha' "$workflow_templates" 0 + # shellcheck disable=SC2016 # the template holds a literal GitHub expression sha_checkouts=$(grep -cF 'ref: ${{ inputs.sha }}' "$kit/templates/ci-check.yml") if [ "$sha_checkouts" -eq 2 ]; then ok "both dispatched CI jobs check out the SHA input"; else bad "both dispatched CI jobs check out the SHA input" "found $sha_checkouts SHA checkouts"; fi expect "the dispatched CI keeps the required check job name" 0 '^ name: check$' "$workflow_templates" 0