From 9537e799fd259636912096c51b105f2a58da0be5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 29 Oct 2026 11:38:36 +0200 Subject: [PATCH 1/2] fix(ci): run task sync without private action downloads Keep task validation on trusted base code and post-merge dispatch on reviewed main-branch code. Scope app tokens to Workspace and preserve authoritative task verification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7a92c33-3a59-4b7f-96d9-8b1db4ab6a0a Signed-off-by: Your Name --- .github/workflows/check-task-id.yml | 32 ++++- .github/workflows/notify-task-merged.yml | 33 +++-- architecture/build.md | 8 ++ scripts/check_task_id.py | 147 +++++++++++++++++++++++ scripts/test_check_task_id.py | 101 ++++++++++++++++ 5 files changed, 309 insertions(+), 12 deletions(-) create mode 100644 scripts/check_task_id.py create mode 100644 scripts/test_check_task_id.py diff --git a/.github/workflows/check-task-id.yml b/.github/workflows/check-task-id.yml index f4ef5241ae..386613b31c 100644 --- a/.github/workflows/check-task-id.yml +++ b/.github/workflows/check-task-id.yml @@ -7,13 +7,35 @@ on: pull_request_target: types: [opened, edited, synchronize, reopened] +permissions: + contents: read + jobs: check-task-id: runs-on: ubuntu-latest steps: - - uses: globster-monday/workspace/.github/actions/check-task-id@main + # Only trusted base code runs with the task-sync credential. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + sparse-checkout: scripts/check_task_id.py + sparse-checkout-cone-mode: false + - name: Parse PR task metadata + id: task + run: python3 scripts/check_task_id.py parse + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: workspace + if: steps.task.outputs.task_id != 'none' with: - pr-author: ${{ github.event.pull_request.user.login }} - pr-body: ${{ github.event.pull_request.body }} - app-id: ${{ secrets.TASK_SYNC_APP_ID }} - app-private-key: ${{ secrets.TASK_SYNC_APP_PRIVATE_KEY }} + client-id: ${{ secrets.TASK_SYNC_APP_ID }} + private-key: ${{ secrets.TASK_SYNC_APP_PRIVATE_KEY }} + owner: globster-monday + repositories: workspace + permission-contents: read + - name: Verify Workspace task + if: steps.task.outputs.task_id != 'none' + env: + TASK_ID: ${{ steps.task.outputs.task_id }} + GITHUB_TOKEN: ${{ steps.workspace.outputs.token }} + run: python3 scripts/check_task_id.py verify diff --git a/.github/workflows/notify-task-merged.yml b/.github/workflows/notify-task-merged.yml index 20aa49609e..84959a4d41 100644 --- a/.github/workflows/notify-task-merged.yml +++ b/.github/workflows/notify-task-merged.yml @@ -10,16 +10,35 @@ on: branches: [main] types: [closed] +permissions: + contents: read + jobs: notify: if: github.event.pull_request.merged == true runs-on: ubuntu-latest steps: - - uses: globster-monday/workspace/.github/actions/notify-task-merged@main + # Only the reviewed main-branch merge is trusted with the app credential. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + persist-credentials: false + sparse-checkout: scripts/check_task_id.py + sparse-checkout-cone-mode: false + - name: Parse PR task metadata + id: task + run: python3 scripts/check_task_id.py parse + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: workspace + if: steps.task.outputs.task_id != 'none' with: - pr-author: ${{ github.event.pull_request.user.login }} - pr-body: ${{ github.event.pull_request.body }} - repository: ${{ github.repository }} - merge-sha: ${{ github.event.pull_request.merge_commit_sha }} - app-id: ${{ secrets.TASK_SYNC_APP_ID }} - app-private-key: ${{ secrets.TASK_SYNC_APP_PRIVATE_KEY }} + client-id: ${{ secrets.TASK_SYNC_APP_ID }} + private-key: ${{ secrets.TASK_SYNC_APP_PRIVATE_KEY }} + owner: globster-monday + repositories: workspace + permission-contents: write + - name: Dispatch merged task for Workspace verification + if: steps.task.outputs.task_id != 'none' + env: + GITHUB_TOKEN: ${{ steps.workspace.outputs.token }} + run: python3 scripts/check_task_id.py notify diff --git a/architecture/build.md b/architecture/build.md index b7fc95c759..caecdccd6e 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -269,6 +269,14 @@ the release tag. Required checks run on GitHub Actions. Workflows that use NVIDIA self-hosted runners trigger from copy-pr-bot mirror branches, so trusted PRs are mirrored into `pull-request/` branches before those workflows run. `main` also uses GitHub merge queue so the final queued integration commit is validated before it merges. +The Globster fork's task-ID gate executes its validator from the immutable PR +base commit, never the PR head. A Workspace-scoped GitHub App token reads task +metadata through the API; the public fork does not download private executable +code. Non-task and bot exceptions do not request that token. +The merged-task notification uses reviewed main-branch merge code and a separate +Workspace-scoped dispatch token. Workspace remains responsible for verifying +the merged PR and updating task state. + The high-level CI model: 1. PR-context gate jobs publish required statuses for the PR head commit. diff --git a/scripts/check_task_id.py b/scripts/check_task_id.py new file mode 100644 index 0000000000..c7c5dc60c1 --- /dev/null +++ b/scripts/check_task_id.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Public-fork task validation without downloading private executable code.""" + +import base64 +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +BOT_AUTHORS = { + "release-please[bot]", + "release-please-app-globster[bot]", + "dependabot[bot]", + "github-actions[bot]", +} +WORKSPACE = "/repos/globster-monday/workspace" + + +def parse_task_id(author, body): + if author in BOT_AUTHORS: + return "none" + fields = re.findall(r"^[ \t]*Task-ID:([^\r\n]*)", body or "", re.MULTILINE) + if len(fields) != 1: + raise ValueError("PR body must have exactly one Task-ID field") + task_id = fields[0].strip() + if task_id != "none" and not re.fullmatch(r"[0-9]{2}\.[0-9]{2}\.[0-9]{2}", task_id): + raise ValueError("Task-ID must be NN.NN.NN or none") + return task_id + + +def verify_task(task_id, request): + if not re.fullmatch(r"[0-9]{2}\.[0-9]{2}\.[0-9]{2}", task_id): + raise ValueError("Invalid task identity") + tree = request(f"{WORKSPACE}/git/trees/main?recursive=1") + if tree.get("truncated"): + raise ValueError( + "Workspace tree is incomplete; task identity cannot be verified" + ) + pattern = ( + rf"work/epics/[^/]+/stories/[^/]+/tasks/{re.escape(task_id)}-[^/]+/README\.md" + ) + matches = [ + item["path"] + for item in tree["tree"] + if re.fullmatch(pattern, item.get("path", "")) + ] + if len(matches) != 1: + raise ValueError("Task-ID must resolve to exactly one Workspace task") + path = urllib.parse.quote(matches[0], safe="/") + content = request(f"{WORKSPACE}/contents/{path}?ref=main") + if content.get("encoding") != "base64": + raise ValueError("Workspace task content is unavailable") + readme = base64.b64decode(content["content"]).decode("utf-8") + frontmatter = re.match(r"\A---\r?\n(.*?)\r?\n---(?:\r?\n|$)", readme, re.DOTALL) + if not frontmatter: + raise ValueError("Workspace task metadata is unavailable") + + def field(name): + values = re.findall(rf"^{name}:[ \t]*([^\r\n]*)", frontmatter[1], re.MULTILINE) + if len(values) != 1: + raise ValueError("Workspace task metadata is ambiguous") + return values[0].split("#", 1)[0].strip().strip("'\"") + + if field("type") != "task" or field("id") != task_id: + raise ValueError("Workspace task identity does not match") + if field("status") not in {"ready", "in_progress"}: + raise ValueError("Workspace task is not actionable") + if not re.fullmatch(r"[0-9]+", field("monday_item_id")): + raise ValueError("Workspace task has not been mirrored") + + +def merged_task_event(event): + pr = event["pull_request"] + if not pr.get("merged") or pr["base"]["ref"] != "main": + raise ValueError("Only a PR merged into main may complete a task") + task_id = parse_task_id(pr["user"]["login"], pr.get("body")) + if task_id == "none": + return None + repository = pr["base"]["repo"]["full_name"] + merge_sha = pr["merge_commit_sha"] + if not re.fullmatch(r"[0-9a-f]{40}", merge_sha): + raise ValueError("Missing authoritative merge commit") + return { + "event_type": "task-merged", + "client_payload": { + "task_id": task_id, + "commit_url": f"https://github.com/{repository}/commit/{merge_sha}", + "repository": repository, + "pr_number": str(pr["number"]), + "pr_url": pr["html_url"], + "pr_title": pr["title"], + "pr_author_id": str(pr["user"]["id"]), + "pr_author_login": pr["user"]["login"], + }, + } + + +def github_request(path, data=None): + request = urllib.request.Request( + f"https://api.github.com{path}", + data=json.dumps(data).encode() if data is not None else None, + headers={ + "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(request, timeout=30) as response: + return None if response.status == 204 else json.load(response) + + +def main(): + if sys.argv[1:] == ["parse"]: + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + pr = event["pull_request"] + task_id = parse_task_id(pr["user"]["login"], pr.get("body")) + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: + output.write(f"task_id={task_id}\n") + elif sys.argv[1:] == ["verify"]: + verify_task(os.environ["TASK_ID"], github_request) + print("Workspace task identity, actionable status and mirror verified") + elif sys.argv[1:] == ["notify"]: + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + payload = merged_task_event(event) + if payload is not None: + github_request(f"{WORKSPACE}/dispatches", payload) + print("Merged task dispatched to Workspace for authoritative verification") + else: + raise ValueError("Expected parse, verify or notify") + + +if __name__ == "__main__": + try: + main() + except (ValueError, KeyError, TypeError, urllib.error.URLError): + print( + "::error::Task metadata validation failed; check the PR and task-sync app access" + ) + sys.exit(1) diff --git a/scripts/test_check_task_id.py b/scripts/test_check_task_id.py new file mode 100644 index 0000000000..e3c61306c9 --- /dev/null +++ b/scripts/test_check_task_id.py @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import base64 +import unittest + +from check_task_id import merged_task_event, parse_task_id, verify_task + + +class TaskIdTests(unittest.TestCase): + def test_exact_field_and_release_exceptions(self): + self.assertEqual( + parse_task_id("owner", "Summary\r\nTask-ID: 34.01.03\r\n"), "34.01.03" + ) + self.assertEqual(parse_task_id("owner", "Task-ID: none"), "none") + self.assertEqual(parse_task_id("dependabot[bot]", None), "none") + for body in [None, "Task-ID: 34.1.3", "Task-ID: 34.01.03\nTask-ID: none"]: + with self.subTest(body=body), self.assertRaises(ValueError): + parse_task_id("owner", body) + + def test_only_one_actionable_mirrored_matching_task_is_accepted(self): + path = "work/epics/34-capacity/stories/34.01-reserve/tasks/34.01.03-binding/README.md" + original = ( + "---\ntype: task\nid: '34.01.03'\nstatus: ready\n" + "monday_item_id: 123\n---\n# Task\n" + ) + for change in [ + None, + ("status: ready", "status: in_progress"), + ("status: ready", "status: blocked"), + ("status: ready", "status: ready\nstatus: in_progress"), + ("monday_item_id: 123", "monday_item_id: null"), + ("id: '34.01.03'", "id: '34.01.04'"), + ("type: task", "type: story"), + ]: + readme = original if change is None else original.replace(*change) + + def request(endpoint, readme=readme): + if "/git/trees/" in endpoint: + return {"tree": [{"path": path}]} + return { + "encoding": "base64", + "content": base64.b64encode(readme.encode()).decode(), + } + + with self.subTest(change=change): + if change is None or change[1] == "status: in_progress": + verify_task("34.01.03", request) + else: + with self.assertRaises(ValueError): + verify_task("34.01.03", request) + + for tree in [ + {"tree": []}, + {"tree": [{"path": path}, {"path": path}]}, + {"tree": [{"path": path}], "truncated": True}, + ]: + with self.subTest(tree=tree), self.assertRaises(ValueError): + verify_task("34.01.03", lambda _, tree=tree: tree) + + def test_completion_requires_a_main_merge_and_preserves_receiver_contract(self): + pr = { + "merged": True, + "base": {"ref": "main", "repo": {"full_name": "globster-monday/OpenShell"}}, + "body": "Task-ID: 34.01.03", + "merge_commit_sha": "a" * 40, + "number": 32, + "html_url": "https://github.com/globster-monday/OpenShell/pull/32", + "title": 'Title with "quotes"\nand newlines', + "user": {"id": 123, "login": "owner"}, + } + payload = merged_task_event({"pull_request": pr}) + self.assertEqual(payload["event_type"], "task-merged") + self.assertEqual( + payload["client_payload"], + { + "task_id": "34.01.03", + "commit_url": "https://github.com/globster-monday/OpenShell/commit/" + + "a" * 40, + "repository": "globster-monday/OpenShell", + "pr_number": "32", + "pr_url": pr["html_url"], + "pr_title": pr["title"], + "pr_author_id": "123", + "pr_author_login": "owner", + }, + ) + for change in [ + {"merged": False}, + {"base": {"ref": "parent-task"}}, + {"merge_commit_sha": "not-a-commit"}, + ]: + with self.subTest(change=change), self.assertRaises(ValueError): + merged_task_event({"pull_request": pr | change}) + self.assertIsNone( + merged_task_event({"pull_request": pr | {"body": "Task-ID: none"}}) + ) + + +if __name__ == "__main__": + unittest.main() From 78de81b9da3f115fb1b06e3bfba955dd35283e7b Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 29 Oct 2026 12:22:10 +0200 Subject: [PATCH 2/2] fix(ci): pin privileged task workflows to trusted default code Use github.sha for both checkouts instead of pull-request-selected refs, resolving the notifier CodeQL finding without disabling analysis. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7a92c33-3a59-4b7f-96d9-8b1db4ab6a0a Signed-off-by: Your Name --- .github/workflows/check-task-id.yml | 4 ++-- .github/workflows/notify-task-merged.yml | 4 ++-- architecture/build.md | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/check-task-id.yml b/.github/workflows/check-task-id.yml index 386613b31c..5ee0dc0f5f 100644 --- a/.github/workflows/check-task-id.yml +++ b/.github/workflows/check-task-id.yml @@ -14,10 +14,10 @@ jobs: check-task-id: runs-on: ubuntu-latest steps: - # Only trusted base code runs with the task-sync credential. + # pull_request_target pins github.sha to trusted default-branch code. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.pull_request.base.sha }} + ref: ${{ github.sha }} persist-credentials: false sparse-checkout: scripts/check_task_id.py sparse-checkout-cone-mode: false diff --git a/.github/workflows/notify-task-merged.yml b/.github/workflows/notify-task-merged.yml index 84959a4d41..e696cdb206 100644 --- a/.github/workflows/notify-task-merged.yml +++ b/.github/workflows/notify-task-merged.yml @@ -18,10 +18,10 @@ jobs: if: github.event.pull_request.merged == true runs-on: ubuntu-latest steps: - # Only the reviewed main-branch merge is trusted with the app credential. + # Never select privileged executable code from PR payload fields. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.pull_request.merge_commit_sha }} + ref: ${{ github.sha }} persist-credentials: false sparse-checkout: scripts/check_task_id.py sparse-checkout-cone-mode: false diff --git a/architecture/build.md b/architecture/build.md index caecdccd6e..ffe46a54e8 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -269,11 +269,12 @@ the release tag. Required checks run on GitHub Actions. Workflows that use NVIDIA self-hosted runners trigger from copy-pr-bot mirror branches, so trusted PRs are mirrored into `pull-request/` branches before those workflows run. `main` also uses GitHub merge queue so the final queued integration commit is validated before it merges. -The Globster fork's task-ID gate executes its validator from the immutable PR -base commit, never the PR head. A Workspace-scoped GitHub App token reads task +The Globster fork's task-ID gate executes its validator from the immutable +trusted default-branch commit (`github.sha`), never a PR-selected ref. +A Workspace-scoped GitHub App token reads task metadata through the API; the public fork does not download private executable code. Non-task and bot exceptions do not request that token. -The merged-task notification uses reviewed main-branch merge code and a separate +The merged-task notification uses the same trusted default-branch code and a separate Workspace-scoped dispatch token. Workspace remains responsible for verifying the merged PR and updating task state.