From 5cafe1b23db257bdd6f3c15f31286a3511afdae0 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Mon, 28 Sep 2026 23:10:04 -0700 Subject: [PATCH 1/8] fix(ci): restore upstream repository gates safely Keep the canonical microsoft/scope execution gate while distinguishing upstream fork-head PRs from fork repository workflows. Run secret-free queue checks on upstream PRs and keep credentials and OIDC out of fork code. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 128 +++++--------------- CONTRIBUTING.md | 61 ++++++++++ package.json | 4 +- pnpm-lock.yaml | 12 ++ scripts/ci-workflow.test.ts | 235 ++++++++++++++++++++++++++++++++++++ 5 files changed, 339 insertions(+), 101 deletions(-) create mode 100644 scripts/ci-workflow.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b45b86a..c5e6e95d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,10 +37,7 @@ on: default: "" permissions: - id-token: write contents: read - pull-requests: write - issues: write concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -101,7 +98,7 @@ jobs: name: coverage-reports-node-${{ matrix.node-version }} path: coverage/ - - name: Post coverage summary to Pull Request + - name: Write coverage summary if: always() && github.event_name == 'pull_request' run: | node -e " @@ -121,19 +118,6 @@ jobs: ]; fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, lines.join('\n') + '\n'); " - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Post test results to Pull Request - if: always() && github.event_name == 'pull_request' - run: | - pnpm exec github-actions-ctrf pull-request "ctrf/*-ctrf-report.json" --title "Test Results (Node.js ${{ matrix.node-version }})" || { - echo "⚠️ Failed to post PR comment (may be due to permissions)" - echo "✅ Test results are still available in the Actions summary above" - exit 0 - } - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload CTRF test results uses: actions/upload-artifact@v4 @@ -148,14 +132,14 @@ jobs: # orientation eval (origin: #1225) lives here today; adding more *.eval.test.ts # files makes them run in this same job. Path-filtered to eval files + prompt-gen # source and required via CI Summary (success-or-skipped). Self-skips when no LLM - # token is present. + # token is present. Never pass a PAT to a fork PR. # --------------------------------------------------------------------------- llm-evals: name: LLM Evals needs: [detect-changes] if: > - github.event_name == 'workflow_dispatch' || - needs.detect-changes.outputs.evals == 'true' + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && + (github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.evals == 'true') runs-on: ubuntu-latest permissions: contents: read @@ -214,7 +198,8 @@ jobs: name: Integration Tests (${{ matrix.worker.name }}) needs: [detect-changes] if: >- - github.repository == 'growth-ecosystems/scope-core' && + github.repository == 'microsoft/scope' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && ( github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.coder-acp-copilot == 'true' || @@ -227,23 +212,6 @@ jobs: fail-fast: false matrix: worker: - - name: vscode-web - images: | - docker buildx build \ - --build-arg VSCODE_VERSION=$VSCODE_VERSION \ - --build-arg COPILOT_CHAT_VERSION=$COPILOT_CHAT_VERSION \ - --cache-from type=local,src=/tmp/.buildx-cache \ - --cache-to type=local,dest=/tmp/.buildx-cache-new,mode=min \ - --load \ - . - docker buildx build \ - --target dev \ - --build-arg VSCODE_VERSION=$VSCODE_VERSION \ - --build-arg COPILOT_CHAT_VERSION=$COPILOT_CHAT_VERSION \ - --cache-from type=local,src=/tmp/.buildx-cache \ - --cache-to type=local,dest=/tmp/.buildx-cache-new,mode=min \ - --load \ - . - name: copilot-acp dockerfile: apps/workers/coder-acp-copilot/Dockerfile versions_env: apps/workers/coder-acp-copilot/versions.env @@ -259,16 +227,6 @@ jobs: --cache-to type=local,dest=/tmp/.buildx-cache-new,mode=min \ --load \ . - - name: vscode-electron - images: | - docker buildx build \ - --target dev \ - --build-arg VSCODE_VERSION=$VSCODE_VERSION \ - --build-arg COPILOT_CHAT_VERSION=$COPILOT_CHAT_VERSION \ - --cache-from type=local,src=/tmp/.buildx-cache \ - --cache-to type=local,dest=/tmp/.buildx-cache-new,mode=min \ - --load \ - . - name: claude-code-acp dockerfile: apps/workers/coder-acp-claude-code/Dockerfile versions_env: apps/workers/coder-acp-claude-code/versions.env @@ -330,12 +288,6 @@ jobs: key: docker-${{ matrix.worker.name }}-${{ hashFiles(matrix.worker.dockerfile, matrix.worker.versions_env) }} restore-keys: docker-${{ matrix.worker.name }}- - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Pre-build Docker test images run: | ${{ matrix.worker.images }} @@ -348,10 +300,7 @@ jobs: - name: Run integration tests run: npx vitest run --config vitest.integration.config.ts --silent=false ${{ matrix.worker.test_pattern }} env: - GH_AUTH_USERNAME: ${{ secrets.GH_AUTH_USERNAME }} - GH_AUTH_PASSWORD: ${{ secrets.GH_AUTH_PASSWORD }} - GH_AUTH_TOTP_SECRET: ${{ secrets.GH_AUTH_TOTP_SECRET }} - GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ matrix.worker.name == 'copilot-acp' && secrets.COPILOT_GITHUB_TOKEN || '' }} # NOTE: Claude Code credentials disabled — out of budget. # The expensive "completes coding prompts" test auto-skips when # these are absent; tool-check tests still run. @@ -379,23 +328,6 @@ jobs: path: test-snapshots/ if-no-files-found: ignore - - name: Collect test videos - if: always() - run: | - mkdir -p test-videos - i=0; find apps/workers/*/test-output -name "*.webm" | sort | while read f; do - cp "$f" "test-videos/recording-$i.webm" - i=$((i+1)) - done - - - name: Upload test videos - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-videos-${{ matrix.worker.name }} - path: test-videos/ - if-no-files-found: ignore - # --------------------------------------------------------------------------- # Queue recovery integration tests (stuck-run fix). Self-provision their own # MongoDB/Redis/Azurite via testcontainers — no docker-compose infra needed. @@ -404,7 +336,7 @@ jobs: name: Integration Tests (queue recovery) needs: [detect-changes] if: >- - github.repository == 'growth-ecosystems/scope-core' && + github.repository == 'microsoft/scope' && ( github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.shared == 'true' || @@ -439,14 +371,7 @@ jobs: - name: Build shared packages run: pnpm --filter shared build - # mongo:7.0 and redis are pulled from Docker Hub; authenticate to avoid - # anonymous pull-rate limits. (Azurite comes from mcr.microsoft.com.) - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - + # Pull public MongoDB/Redis/Azurite images anonymously, including on forks. - name: Run queue recovery integration tests run: pnpm vitest run --config vitest.integration.config.ts --silent=false packages/shared/src/queue/redelivery.integration.test.ts apps/scheduler/src/stuck-run-reaper.integration.test.ts @@ -661,16 +586,6 @@ jobs: run: | npx github-actions-ctrf "ctrf/gateway-ctrf-report.json" --title "Gateway: lint, test & build" - - name: Post test results to Pull Request - if: always() && github.event_name == 'pull_request' - run: | - npx github-actions-ctrf pull-request "ctrf/gateway-ctrf-report.json" --title "Gateway: lint, test & build" || { - echo "⚠️ Failed to post PR comment (may be due to permissions)" - exit 0 - } - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Upload CTRF test results uses: actions/upload-artifact@v4 if: always() @@ -754,6 +669,9 @@ jobs: # --------------------------------------------------------------------------- detect-changes: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read outputs: api: ${{ steps.changes.outputs.api }} judge: ${{ steps.changes.outputs.judge }} @@ -783,6 +701,7 @@ jobs: base: ${{ github.event.before }} filters: | shared: + - '.github/workflows/ci.yml' - 'packages/shared/**' - 'package.json' - 'pnpm-lock.yaml' @@ -820,6 +739,8 @@ jobs: - 'apps/workers/coder-acp-copilot-windows/**' - 'apps/workers/coder-acp-copilot/versions.env' typescript: + - '.github/workflows/ci.yml' + - 'scripts/ci-workflow.test.ts' - 'apps/**' - '!apps/gateway/**' - 'packages/**' @@ -843,7 +764,8 @@ jobs: if: >- always() && !cancelled() && - github.repository == 'growth-ecosystems/scope-core' && + github.repository == 'microsoft/scope' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (needs.test.result == 'success' || needs.test.result == 'skipped') && (needs.build.result == 'success' || needs.build.result == 'skipped') && (needs.gateway.result == 'success' || needs.gateway.result == 'skipped') && @@ -872,6 +794,9 @@ jobs: needs: [test, integration-test, integration-test-queue, build, gateway, detect-changes] runs-on: ubuntu-latest environment: integration + permissions: + contents: read + id-token: write strategy: fail-fast: false matrix: @@ -924,8 +849,9 @@ jobs: - name: Check if image should be built (manual trigger) id: check if: github.event_name == 'workflow_dispatch' + env: + IMAGES: ${{ inputs.images }} run: | - IMAGES="${{ github.event.inputs.images }}" if [[ "$IMAGES" == "all" ]] || echo "$IMAGES" | grep -qw "${{ matrix.image.name }}"; then echo "skip=false" >> "$GITHUB_OUTPUT" else @@ -977,8 +903,6 @@ jobs: VERSION_PREFIX="copilot-${COPILOT_CLI_VERSION}" ;; coder-acp-claude-code) VERSION_PREFIX="claude-agent-acp-${CLAUDE_CODE_ACP_VERSION}-sdk-${CLAUDE_AGENT_SDK_VERSION}" ;; - VERSION_PREFIX="vscode-${VSCODE_VERSION}-copilot-${COPILOT_CHAT_VERSION}" ;; - VERSION_PREFIX="vscode-electron-${VSCODE_VERSION}-copilot-${COPILOT_CHAT_VERSION}" ;; esac if [[ "${{ github.event_name }}" == "pull_request" ]]; then @@ -1051,7 +975,8 @@ jobs: if: >- always() && !cancelled() && - github.repository == 'growth-ecosystems/scope-core' && + github.repository == 'microsoft/scope' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (needs.test.result == 'success' || needs.test.result == 'skipped') && ( (github.event_name == 'workflow_dispatch' && (github.event.inputs.images == 'all' || contains(github.event.inputs.images, 'coder-acp-copilot-windows'))) || @@ -1060,6 +985,9 @@ jobs: needs: [test, detect-changes] runs-on: ubuntu-latest environment: integration + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v4 @@ -1123,7 +1051,7 @@ jobs: WORKER_DIR="apps/workers/coder-acp-copilot-windows" ACR_LOGIN_SERVER="${{ steps.base.outputs.acr_login_server }}" - DEPS_HASH=$(cat ${WORKER_DIR}/Dockerfile.deps ${WORKER_DIR}/versions.env ${WORKER_DIR}/Dockerfile.base | sha256sum | cut -c1-12) + DEPS_HASH=$(cat ${WORKER_DIR}/Dockerfile.deps apps/workers/coder-acp-copilot/versions.env ${WORKER_DIR}/Dockerfile.base | sha256sum | cut -c1-12) DEPS_TAG="deps-${DEPS_HASH}" DEPS_IMAGE="${ACR_LOGIN_SERVER}/scoped/coder-acp-copilot-windows-deps:${DEPS_TAG}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 691467f6..b254a293 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,6 +119,67 @@ pnpm test:coverage # Unit tests with a coverage report pnpm test:integration # Integration tests (requires a .env file + Docker) ``` +### CI repository and fork boundaries + +The four repository-gated jobs in [CI](./.github/workflows/ci.yml) intentionally +require `github.repository == 'microsoft/scope'`. They must not run when the +workflow executes in a fork repository. Do not remove this restriction or +replace it with an opt-in variable. + +A fork **PR into upstream** is different: its workflow executes in +`microsoft/scope`, but its head code is untrusted. + +| Job | Upstream fork-head PR | Upstream same-repository PR, main push, or manual run | +| --- | --- | --- | +| Queue recovery integration | Runs when selected | Runs when selected | +| Worker integration | Skipped | Runs when selected | +| Linux / Windows image publishing | Skipped | Runs when selected, with existing prerequisite checks | + +Selection still depends on the existing changed paths and manual `images` +input. Other public validation jobs remain available; this is not a global +fork-CI disable switch. LLM evals also skip fork-head PRs because they use a PAT. +All CI jobs use GitHub-hosted Ubuntu runners, not self-hosted runners. + +Queue recovery provisions disposable MongoDB, Redis, and Azurite containers and +pulls public images without Docker Hub credentials. Validation jobs use read-only +GitHub permissions; test results and coverage remain in Actions summaries and +artifacts rather than PR comments. Only image-publishing jobs request OIDC. +Neither approval of a fork workflow nor a label grants it access to privileged +jobs. Never use `pull_request_target` to execute fork code. + +**Maintainer prerequisites:** repository identity is necessary, not proof that +cloud credentials or infrastructure exist. Before relying on the restored jobs: + +- For live Copilot worker tests and LLM evals, provide `COPILOT_GITHUB_TOKEN` as a + repository/organization secret with the required Copilot entitlement / GitHub + Models access. Without it, existing live tests self-skip; that is not evidence + they passed. Claude live-prompt credentials remain disabled; its tool checks + still run. The worker matrix covers the current Copilot and Claude ACP workers, + not the removed VS Code workers. +- For publishing, configure the `integration` environment with + `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID`, `ACR_NAME`, and + `ACR_RESOURCE_GROUP` variables. Configure Azure federation for + `repo:microsoft/scope:environment:integration`, with registry-scoped read/push + permissions and ACR Tasks build permissions for Windows. Missing setup is a + publishing failure, not a reason to bypass checks. +- Protect the environment with required reviewers and deployment-ref restrictions + matching the release policy, including any reviewed same-repository PR refs + allowed to publish. Protect workflow changes through code review. Do not grant + Azure trust or privileged runners to fork repositories to make their CI pass. + +Gate regression tests evaluate the actual workflow expressions, distinguish the +workflow repository from the PR head repository, and check failure/cancellation +handling. Run the focused checks with: + +```bash +pnpm exec vitest run scripts/ci-workflow.test.ts +pnpm test:integration:queue # Real disposable services; Docker required +go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 -shellcheck= .github/workflows/ci.yml +``` + +The regression tests also parse inline Bash with `bash -n`; the actionlint command +does not require a local ShellCheck installation. + ## Build, lint, and typecheck ```bash diff --git a/package.json b/package.json index d7c72039..98b628af 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "k3d:logs": "kubectl -n scoped logs -f --all-containers --prefix -l app.kubernetes.io/part-of=scoped --max-log-requests=20" }, "devDependencies": { + "@actions/expressions": "^0.3.61", "@d2t/vitest-ctrf-json-reporter": "^1.3.0", "@vitest/coverage-v8": "^4.1.0", "concurrently": "^9.2.1", @@ -88,7 +89,8 @@ "github-actions-ctrf": "^0.0.58", "tsx": "^4.21.0", "vitest": "^4.1.0", - "worktree-env": "^0.1.2" + "worktree-env": "^0.1.2", + "yaml": "^2.8.3" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cecd95e1..da8e71c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: .: devDependencies: + '@actions/expressions': + specifier: ^0.3.61 + version: 0.3.61 '@d2t/vitest-ctrf-json-reporter': specifier: ^1.3.0 version: 1.3.0 @@ -71,6 +74,9 @@ importers: worktree-env: specifier: ^0.1.2 version: 0.1.2 + yaml: + specifier: ^2.8.3 + version: 2.8.3 apps/api: dependencies: @@ -978,6 +984,10 @@ packages: '@actions/exec@1.1.1': resolution: {integrity: sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==} + '@actions/expressions@0.3.61': + resolution: {integrity: sha512-Ig+tXELvTjlqxQAWBQnLNn2dPpNraLMdSnT+vDzDPmJtbNUhIAH/Z9kVT8pZ8UtJi9vUGs8hwHE8JFJmbOnLEA==} + engines: {node: '>= 20'} + '@actions/github@6.0.1': resolution: {integrity: sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw==} @@ -6988,6 +6998,8 @@ snapshots: dependencies: '@actions/io': 1.1.3 + '@actions/expressions@0.3.61': {} + '@actions/github@6.0.1': dependencies: '@actions/http-client': 2.2.3 diff --git a/scripts/ci-workflow.test.ts b/scripts/ci-workflow.test.ts new file mode 100644 index 00000000..c3d50d41 --- /dev/null +++ b/scripts/ci-workflow.test.ts @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { data, Evaluator, Lexer, Parser } from "@actions/expressions"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +interface Step { + name?: string; + uses?: string; + run?: string; + if?: string; + env?: Record; + with?: Record; +} + +interface Job { + if?: string; + needs?: string[]; + "runs-on": string; + environment?: string; + permissions?: Record; + steps: Step[]; + strategy?: { + matrix: { + worker: { dockerfile: string; versions_env: string; test_pattern: string; images: string }[]; + }; + }; +} + +interface Workflow { + on: Record; + permissions: Record; + jobs: Record; +} + +const workflow: Workflow = parse(readFileSync(".github/workflows/ci.yml", "utf8")); +const repositoryGated = ["integration-test", "integration-test-queue", "build-images", "build-windows-image"]; +const allChanges = { + shared: "true", + typescript: "true", + evals: "true", + gateway: "true", + scheduler: "true", + "coder-acp-copilot": "true", + "coder-acp-claude-code": "true", + "coder-acp-copilot-windows": "true", +}; + +function context(options: { + event?: string; + ref?: string; + repository?: string; + headRepository?: string; + changes?: Record; + results?: Record; + images?: string; +} = {}) { + return { + github: { + event_name: options.event ?? "push", + ref: options.ref ?? "refs/heads/main", + repository: options.repository ?? "microsoft/scope", + event: { + inputs: { images: options.images ?? "all" }, + pull_request: { head: { repo: { full_name: options.headRepository ?? options.repository ?? "microsoft/scope" } } }, + }, + }, + needs: Object.fromEntries(Object.keys(workflow.jobs).map((name) => [name, { + result: options.results?.[name] ?? "success", + outputs: name === "detect-changes" ? options.changes ?? allChanges : {}, + }])), + }; +} + +function evaluate(expression: string, values: ReturnType, cancelled = false): data.ExpressionData { + const functions = new Map([ + ["always", { name: "always", minArgs: 0, maxArgs: 0, call: () => new data.BooleanData(true) }], + ["cancelled", { name: "cancelled", minArgs: 0, maxArgs: 0, call: () => new data.BooleanData(cancelled) }], + ]); + const { tokens } = new Lexer(expression.replace(/^\$\{\{|\}\}$/g, "").trim()).lex(); + const parsed = new Parser(tokens, Object.keys(values), [...functions.values()]).parse(); + const converted: unknown = JSON.parse(JSON.stringify(values), data.reviver); + if (!(converted instanceof data.Dictionary)) throw new Error("Expected an Actions context dictionary"); + return new Evaluator(parsed, converted, functions).evaluate(); +} + +function enabled(job: Job, values: ReturnType, cancelled = false): boolean { + if (!job.if) throw new Error("Expected an explicit job gate"); + return evaluate(job.if, values, cancelled).coerceString() === "true"; +} + +describe("canonical repository CI gates", () => { + for (const name of repositoryGated) { + const job = workflow.jobs[name]; + + it(`${name} runs only in microsoft/scope, never in fork repositories or the retired repository`, () => { + for (const repository of ["microsoft/scope", "growth-ecosystems/scope-core", "maintainer/scope"]) { + for (const event of ["push", "pull_request", "workflow_dispatch"]) { + expect(enabled(job, context({ repository, event }))).toBe(repository === "microsoft/scope"); + } + } + }); + + it(`${name} distinguishes upstream fork PRs from workflows executing in forks`, () => { + const upstreamForkPR = context({ + event: "pull_request", ref: "refs/pull/42/merge", headRepository: "contributor/fork", + }); + expect(enabled(job, upstreamForkPR)).toBe(name === "integration-test-queue"); + expect(enabled(job, context({ + event: "pull_request", ref: "refs/pull/42/merge", headRepository: "microsoft/scope", + }))).toBe(true); + }); + + it(`${name} preserves path and manual image selection gates`, () => { + expect(enabled(job, context({ changes: {} }))).toBe(false); + const publishing = name.startsWith("build-"); + expect(enabled(job, context({ event: "workflow_dispatch", changes: {}, images: "" }))).toBe(!publishing); + }); + } + + it("runs queue recovery on upstream fork PRs without credentials", () => { + const job = workflow.jobs["integration-test-queue"]; + for (const event of ["push", "pull_request", "workflow_dispatch"]) { + expect(enabled(job, context({ event, headRepository: "contributor/fork" }))).toBe(true); + } + for (const changed of ["shared", "scheduler"]) { + expect(enabled(job, context({ event: "pull_request", headRepository: "contributor/fork", changes: { [changed]: "true" } }))).toBe(true); + } + expect(enabled(job, context({ event: "pull_request", changes: {} }))).toBe(false); + expect(job.environment).toBeUndefined(); + expect(JSON.stringify(job)).not.toMatch(/secrets\.|docker\/login-action|azure\/login/); + }); + + it("grants OIDC only to publishers and no write token to public PR validation", () => { + expect(workflow.permissions).toEqual({ contents: "read" }); + expect(workflow.on).not.toHaveProperty("pull_request_target"); + for (const [name, job] of Object.entries(workflow.jobs)) { + expect(job["runs-on"]).toBe("ubuntu-latest"); + const publishing = ["build-images", "build-windows-image"].includes(name); + if (publishing) { + expect(job.permissions).toEqual({ contents: "read", "id-token": "write" }); + expect(job.environment).toBe("integration"); + } else { + expect(Object.values(job.permissions ?? {})).not.toContain("write"); + } + if (JSON.stringify(job).includes("secrets.") || publishing) { + expect(enabled(job, context({ event: "pull_request", headRepository: "contributor/fork" }))).toBe(false); + } + } + expect(workflow.jobs["detect-changes"].permissions).toEqual({ contents: "read", "pull-requests": "read" }); + }); + + it("preserves prerequisite failure and cancellation gates for publishing", () => { + for (const name of ["build-images", "build-windows-image"]) { + const job = workflow.jobs[name]; + for (const prerequisite of job.needs!.filter((dependency) => dependency !== "detect-changes")) { + for (const result of ["failure", "cancelled"]) { + expect(enabled(job, context({ results: { [prerequisite]: result } }))).toBe(false); + } + expect(enabled(job, context({ results: { [prerequisite]: "skipped" } }))).toBe(true); + } + expect(enabled(job, context(), true)).toBe(false); + } + }); + + it("has only current worker matrix entries with explicit test selection", () => { + const workers = workflow.jobs["integration-test"].strategy!.matrix.worker; + expect(workers).toHaveLength(2); + for (const worker of workers) { + expect(existsSync(worker.dockerfile)).toBe(true); + expect(existsSync(worker.versions_env)).toBe(true); + expect(existsSync(worker.test_pattern)).toBe(true); + expect(worker.images).toContain(`-f ${worker.dockerfile}`); + } + }); + + it("runs regression tests and queue tests when their CI configuration changes", () => { + const filterStep = workflow.jobs["detect-changes"].steps.find((step) => step.uses?.startsWith("dorny/paths-filter@")); + const filters: Record = parse(filterStep!.with!.filters); + expect(filters.typescript).toContain("scripts/ci-workflow.test.ts"); + expect(filters.typescript).toContain(".github/workflows/ci.yml"); + expect(filters.shared).toContain(".github/workflows/ci.yml"); + }); +}); + +describe("CI status aggregation", () => { + const summary = workflow.jobs["ci-summary"]; + const step = summary.steps[0]; + + function runSummary(values: ReturnType): number | null { + const script = step.run!.replace(/\$\{\{(.*?)\}\}/g, (_, expression: string) => + evaluate(expression, values).coerceString()); + const env = Object.fromEntries(Object.entries(step.env ?? {}).map(([name, expression]) => + [name, evaluate(expression, values).coerceString()])); + return spawnSync("bash", ["-e", "-c", script], { env, encoding: "utf8" }).status; + } + + it("accepts successful checks and intentionally disabled privileged checks", () => { + expect(summary.if).toBe("always()"); + expect(runSummary(context())).toBe(0); + expect(runSummary(context({ + event: "pull_request", + results: { "integration-test": "skipped", "llm-evals": "skipped" }, + }))).toBe(0); + }); + + for (const name of summary.needs!) { + it(`fails when ${name} fails or is cancelled`, () => { + for (const result of ["failure", "cancelled"]) { + expect(runSummary(context({ results: { [name]: result } }))).toBe(1); + } + }); + } + + it("does not relax required unit, lint, build, or license checks", () => { + for (const name of ["test", "lint", "build", "license-headers"]) { + expect(runSummary(context({ results: { [name]: "skipped" } }))).toBe(1); + } + }); +}); + +it("keeps all inline Bash scripts syntactically valid", () => { + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps) { + if (!step.run) continue; + const script = step.run.replace(/\$\{\{.*?\}\}/g, "placeholder"); + const result = spawnSync("bash", ["-n"], { input: script, encoding: "utf8" }); + expect(result.stderr, step.name).toBe(""); + expect(result.status, step.name).toBe(0); + } + } +}); From d28f2980c35e16a6618d4e1c80f5dbe7d22cde69 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Mon, 28 Sep 2026 23:22:09 -0700 Subject: [PATCH 2/8] fix(ci): limit repository gate correction to four replacements Restore all unrelated workflow, documentation, dependency, and test changes. The aggregate PR diff now only replaces the retired repository name with microsoft/scope in the four existing gates. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 120 ++++++++++++++---- CONTRIBUTING.md | 61 ---------- package.json | 4 +- pnpm-lock.yaml | 12 -- scripts/ci-workflow.test.ts | 235 ------------------------------------ 5 files changed, 97 insertions(+), 335 deletions(-) delete mode 100644 scripts/ci-workflow.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5e6e95d..398fe4fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,10 @@ on: default: "" permissions: + id-token: write contents: read + pull-requests: write + issues: write concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -98,7 +101,7 @@ jobs: name: coverage-reports-node-${{ matrix.node-version }} path: coverage/ - - name: Write coverage summary + - name: Post coverage summary to Pull Request if: always() && github.event_name == 'pull_request' run: | node -e " @@ -118,6 +121,19 @@ jobs: ]; fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, lines.join('\n') + '\n'); " + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Post test results to Pull Request + if: always() && github.event_name == 'pull_request' + run: | + pnpm exec github-actions-ctrf pull-request "ctrf/*-ctrf-report.json" --title "Test Results (Node.js ${{ matrix.node-version }})" || { + echo "⚠️ Failed to post PR comment (may be due to permissions)" + echo "✅ Test results are still available in the Actions summary above" + exit 0 + } + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload CTRF test results uses: actions/upload-artifact@v4 @@ -132,14 +148,14 @@ jobs: # orientation eval (origin: #1225) lives here today; adding more *.eval.test.ts # files makes them run in this same job. Path-filtered to eval files + prompt-gen # source and required via CI Summary (success-or-skipped). Self-skips when no LLM - # token is present. Never pass a PAT to a fork PR. + # token is present. # --------------------------------------------------------------------------- llm-evals: name: LLM Evals needs: [detect-changes] if: > - (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && - (github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.evals == 'true') + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.evals == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -199,7 +215,6 @@ jobs: needs: [detect-changes] if: >- github.repository == 'microsoft/scope' && - (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && ( github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.coder-acp-copilot == 'true' || @@ -212,6 +227,23 @@ jobs: fail-fast: false matrix: worker: + - name: vscode-web + images: | + docker buildx build \ + --build-arg VSCODE_VERSION=$VSCODE_VERSION \ + --build-arg COPILOT_CHAT_VERSION=$COPILOT_CHAT_VERSION \ + --cache-from type=local,src=/tmp/.buildx-cache \ + --cache-to type=local,dest=/tmp/.buildx-cache-new,mode=min \ + --load \ + . + docker buildx build \ + --target dev \ + --build-arg VSCODE_VERSION=$VSCODE_VERSION \ + --build-arg COPILOT_CHAT_VERSION=$COPILOT_CHAT_VERSION \ + --cache-from type=local,src=/tmp/.buildx-cache \ + --cache-to type=local,dest=/tmp/.buildx-cache-new,mode=min \ + --load \ + . - name: copilot-acp dockerfile: apps/workers/coder-acp-copilot/Dockerfile versions_env: apps/workers/coder-acp-copilot/versions.env @@ -227,6 +259,16 @@ jobs: --cache-to type=local,dest=/tmp/.buildx-cache-new,mode=min \ --load \ . + - name: vscode-electron + images: | + docker buildx build \ + --target dev \ + --build-arg VSCODE_VERSION=$VSCODE_VERSION \ + --build-arg COPILOT_CHAT_VERSION=$COPILOT_CHAT_VERSION \ + --cache-from type=local,src=/tmp/.buildx-cache \ + --cache-to type=local,dest=/tmp/.buildx-cache-new,mode=min \ + --load \ + . - name: claude-code-acp dockerfile: apps/workers/coder-acp-claude-code/Dockerfile versions_env: apps/workers/coder-acp-claude-code/versions.env @@ -288,6 +330,12 @@ jobs: key: docker-${{ matrix.worker.name }}-${{ hashFiles(matrix.worker.dockerfile, matrix.worker.versions_env) }} restore-keys: docker-${{ matrix.worker.name }}- + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Pre-build Docker test images run: | ${{ matrix.worker.images }} @@ -300,7 +348,10 @@ jobs: - name: Run integration tests run: npx vitest run --config vitest.integration.config.ts --silent=false ${{ matrix.worker.test_pattern }} env: - GITHUB_TOKEN: ${{ matrix.worker.name == 'copilot-acp' && secrets.COPILOT_GITHUB_TOKEN || '' }} + GH_AUTH_USERNAME: ${{ secrets.GH_AUTH_USERNAME }} + GH_AUTH_PASSWORD: ${{ secrets.GH_AUTH_PASSWORD }} + GH_AUTH_TOTP_SECRET: ${{ secrets.GH_AUTH_TOTP_SECRET }} + GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} # NOTE: Claude Code credentials disabled — out of budget. # The expensive "completes coding prompts" test auto-skips when # these are absent; tool-check tests still run. @@ -328,6 +379,23 @@ jobs: path: test-snapshots/ if-no-files-found: ignore + - name: Collect test videos + if: always() + run: | + mkdir -p test-videos + i=0; find apps/workers/*/test-output -name "*.webm" | sort | while read f; do + cp "$f" "test-videos/recording-$i.webm" + i=$((i+1)) + done + + - name: Upload test videos + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-videos-${{ matrix.worker.name }} + path: test-videos/ + if-no-files-found: ignore + # --------------------------------------------------------------------------- # Queue recovery integration tests (stuck-run fix). Self-provision their own # MongoDB/Redis/Azurite via testcontainers — no docker-compose infra needed. @@ -371,7 +439,14 @@ jobs: - name: Build shared packages run: pnpm --filter shared build - # Pull public MongoDB/Redis/Azurite images anonymously, including on forks. + # mongo:7.0 and redis are pulled from Docker Hub; authenticate to avoid + # anonymous pull-rate limits. (Azurite comes from mcr.microsoft.com.) + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Run queue recovery integration tests run: pnpm vitest run --config vitest.integration.config.ts --silent=false packages/shared/src/queue/redelivery.integration.test.ts apps/scheduler/src/stuck-run-reaper.integration.test.ts @@ -586,6 +661,16 @@ jobs: run: | npx github-actions-ctrf "ctrf/gateway-ctrf-report.json" --title "Gateway: lint, test & build" + - name: Post test results to Pull Request + if: always() && github.event_name == 'pull_request' + run: | + npx github-actions-ctrf pull-request "ctrf/gateway-ctrf-report.json" --title "Gateway: lint, test & build" || { + echo "⚠️ Failed to post PR comment (may be due to permissions)" + exit 0 + } + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload CTRF test results uses: actions/upload-artifact@v4 if: always() @@ -669,9 +754,6 @@ jobs: # --------------------------------------------------------------------------- detect-changes: runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read outputs: api: ${{ steps.changes.outputs.api }} judge: ${{ steps.changes.outputs.judge }} @@ -701,7 +783,6 @@ jobs: base: ${{ github.event.before }} filters: | shared: - - '.github/workflows/ci.yml' - 'packages/shared/**' - 'package.json' - 'pnpm-lock.yaml' @@ -739,8 +820,6 @@ jobs: - 'apps/workers/coder-acp-copilot-windows/**' - 'apps/workers/coder-acp-copilot/versions.env' typescript: - - '.github/workflows/ci.yml' - - 'scripts/ci-workflow.test.ts' - 'apps/**' - '!apps/gateway/**' - 'packages/**' @@ -765,7 +844,6 @@ jobs: always() && !cancelled() && github.repository == 'microsoft/scope' && - (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (needs.test.result == 'success' || needs.test.result == 'skipped') && (needs.build.result == 'success' || needs.build.result == 'skipped') && (needs.gateway.result == 'success' || needs.gateway.result == 'skipped') && @@ -794,9 +872,6 @@ jobs: needs: [test, integration-test, integration-test-queue, build, gateway, detect-changes] runs-on: ubuntu-latest environment: integration - permissions: - contents: read - id-token: write strategy: fail-fast: false matrix: @@ -849,9 +924,8 @@ jobs: - name: Check if image should be built (manual trigger) id: check if: github.event_name == 'workflow_dispatch' - env: - IMAGES: ${{ inputs.images }} run: | + IMAGES="${{ github.event.inputs.images }}" if [[ "$IMAGES" == "all" ]] || echo "$IMAGES" | grep -qw "${{ matrix.image.name }}"; then echo "skip=false" >> "$GITHUB_OUTPUT" else @@ -903,6 +977,8 @@ jobs: VERSION_PREFIX="copilot-${COPILOT_CLI_VERSION}" ;; coder-acp-claude-code) VERSION_PREFIX="claude-agent-acp-${CLAUDE_CODE_ACP_VERSION}-sdk-${CLAUDE_AGENT_SDK_VERSION}" ;; + VERSION_PREFIX="vscode-${VSCODE_VERSION}-copilot-${COPILOT_CHAT_VERSION}" ;; + VERSION_PREFIX="vscode-electron-${VSCODE_VERSION}-copilot-${COPILOT_CHAT_VERSION}" ;; esac if [[ "${{ github.event_name }}" == "pull_request" ]]; then @@ -976,7 +1052,6 @@ jobs: always() && !cancelled() && github.repository == 'microsoft/scope' && - (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (needs.test.result == 'success' || needs.test.result == 'skipped') && ( (github.event_name == 'workflow_dispatch' && (github.event.inputs.images == 'all' || contains(github.event.inputs.images, 'coder-acp-copilot-windows'))) || @@ -985,9 +1060,6 @@ jobs: needs: [test, detect-changes] runs-on: ubuntu-latest environment: integration - permissions: - contents: read - id-token: write steps: - uses: actions/checkout@v4 @@ -1051,7 +1123,7 @@ jobs: WORKER_DIR="apps/workers/coder-acp-copilot-windows" ACR_LOGIN_SERVER="${{ steps.base.outputs.acr_login_server }}" - DEPS_HASH=$(cat ${WORKER_DIR}/Dockerfile.deps apps/workers/coder-acp-copilot/versions.env ${WORKER_DIR}/Dockerfile.base | sha256sum | cut -c1-12) + DEPS_HASH=$(cat ${WORKER_DIR}/Dockerfile.deps ${WORKER_DIR}/versions.env ${WORKER_DIR}/Dockerfile.base | sha256sum | cut -c1-12) DEPS_TAG="deps-${DEPS_HASH}" DEPS_IMAGE="${ACR_LOGIN_SERVER}/scoped/coder-acp-copilot-windows-deps:${DEPS_TAG}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b254a293..691467f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,67 +119,6 @@ pnpm test:coverage # Unit tests with a coverage report pnpm test:integration # Integration tests (requires a .env file + Docker) ``` -### CI repository and fork boundaries - -The four repository-gated jobs in [CI](./.github/workflows/ci.yml) intentionally -require `github.repository == 'microsoft/scope'`. They must not run when the -workflow executes in a fork repository. Do not remove this restriction or -replace it with an opt-in variable. - -A fork **PR into upstream** is different: its workflow executes in -`microsoft/scope`, but its head code is untrusted. - -| Job | Upstream fork-head PR | Upstream same-repository PR, main push, or manual run | -| --- | --- | --- | -| Queue recovery integration | Runs when selected | Runs when selected | -| Worker integration | Skipped | Runs when selected | -| Linux / Windows image publishing | Skipped | Runs when selected, with existing prerequisite checks | - -Selection still depends on the existing changed paths and manual `images` -input. Other public validation jobs remain available; this is not a global -fork-CI disable switch. LLM evals also skip fork-head PRs because they use a PAT. -All CI jobs use GitHub-hosted Ubuntu runners, not self-hosted runners. - -Queue recovery provisions disposable MongoDB, Redis, and Azurite containers and -pulls public images without Docker Hub credentials. Validation jobs use read-only -GitHub permissions; test results and coverage remain in Actions summaries and -artifacts rather than PR comments. Only image-publishing jobs request OIDC. -Neither approval of a fork workflow nor a label grants it access to privileged -jobs. Never use `pull_request_target` to execute fork code. - -**Maintainer prerequisites:** repository identity is necessary, not proof that -cloud credentials or infrastructure exist. Before relying on the restored jobs: - -- For live Copilot worker tests and LLM evals, provide `COPILOT_GITHUB_TOKEN` as a - repository/organization secret with the required Copilot entitlement / GitHub - Models access. Without it, existing live tests self-skip; that is not evidence - they passed. Claude live-prompt credentials remain disabled; its tool checks - still run. The worker matrix covers the current Copilot and Claude ACP workers, - not the removed VS Code workers. -- For publishing, configure the `integration` environment with - `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID`, `ACR_NAME`, and - `ACR_RESOURCE_GROUP` variables. Configure Azure federation for - `repo:microsoft/scope:environment:integration`, with registry-scoped read/push - permissions and ACR Tasks build permissions for Windows. Missing setup is a - publishing failure, not a reason to bypass checks. -- Protect the environment with required reviewers and deployment-ref restrictions - matching the release policy, including any reviewed same-repository PR refs - allowed to publish. Protect workflow changes through code review. Do not grant - Azure trust or privileged runners to fork repositories to make their CI pass. - -Gate regression tests evaluate the actual workflow expressions, distinguish the -workflow repository from the PR head repository, and check failure/cancellation -handling. Run the focused checks with: - -```bash -pnpm exec vitest run scripts/ci-workflow.test.ts -pnpm test:integration:queue # Real disposable services; Docker required -go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 -shellcheck= .github/workflows/ci.yml -``` - -The regression tests also parse inline Bash with `bash -n`; the actionlint command -does not require a local ShellCheck installation. - ## Build, lint, and typecheck ```bash diff --git a/package.json b/package.json index 98b628af..d7c72039 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,6 @@ "k3d:logs": "kubectl -n scoped logs -f --all-containers --prefix -l app.kubernetes.io/part-of=scoped --max-log-requests=20" }, "devDependencies": { - "@actions/expressions": "^0.3.61", "@d2t/vitest-ctrf-json-reporter": "^1.3.0", "@vitest/coverage-v8": "^4.1.0", "concurrently": "^9.2.1", @@ -89,8 +88,7 @@ "github-actions-ctrf": "^0.0.58", "tsx": "^4.21.0", "vitest": "^4.1.0", - "worktree-env": "^0.1.2", - "yaml": "^2.8.3" + "worktree-env": "^0.1.2" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da8e71c7..cecd95e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,9 +47,6 @@ importers: .: devDependencies: - '@actions/expressions': - specifier: ^0.3.61 - version: 0.3.61 '@d2t/vitest-ctrf-json-reporter': specifier: ^1.3.0 version: 1.3.0 @@ -74,9 +71,6 @@ importers: worktree-env: specifier: ^0.1.2 version: 0.1.2 - yaml: - specifier: ^2.8.3 - version: 2.8.3 apps/api: dependencies: @@ -984,10 +978,6 @@ packages: '@actions/exec@1.1.1': resolution: {integrity: sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==} - '@actions/expressions@0.3.61': - resolution: {integrity: sha512-Ig+tXELvTjlqxQAWBQnLNn2dPpNraLMdSnT+vDzDPmJtbNUhIAH/Z9kVT8pZ8UtJi9vUGs8hwHE8JFJmbOnLEA==} - engines: {node: '>= 20'} - '@actions/github@6.0.1': resolution: {integrity: sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw==} @@ -6998,8 +6988,6 @@ snapshots: dependencies: '@actions/io': 1.1.3 - '@actions/expressions@0.3.61': {} - '@actions/github@6.0.1': dependencies: '@actions/http-client': 2.2.3 diff --git a/scripts/ci-workflow.test.ts b/scripts/ci-workflow.test.ts deleted file mode 100644 index c3d50d41..00000000 --- a/scripts/ci-workflow.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; -import { data, Evaluator, Lexer, Parser } from "@actions/expressions"; -import { describe, expect, it } from "vitest"; -import { parse } from "yaml"; - -interface Step { - name?: string; - uses?: string; - run?: string; - if?: string; - env?: Record; - with?: Record; -} - -interface Job { - if?: string; - needs?: string[]; - "runs-on": string; - environment?: string; - permissions?: Record; - steps: Step[]; - strategy?: { - matrix: { - worker: { dockerfile: string; versions_env: string; test_pattern: string; images: string }[]; - }; - }; -} - -interface Workflow { - on: Record; - permissions: Record; - jobs: Record; -} - -const workflow: Workflow = parse(readFileSync(".github/workflows/ci.yml", "utf8")); -const repositoryGated = ["integration-test", "integration-test-queue", "build-images", "build-windows-image"]; -const allChanges = { - shared: "true", - typescript: "true", - evals: "true", - gateway: "true", - scheduler: "true", - "coder-acp-copilot": "true", - "coder-acp-claude-code": "true", - "coder-acp-copilot-windows": "true", -}; - -function context(options: { - event?: string; - ref?: string; - repository?: string; - headRepository?: string; - changes?: Record; - results?: Record; - images?: string; -} = {}) { - return { - github: { - event_name: options.event ?? "push", - ref: options.ref ?? "refs/heads/main", - repository: options.repository ?? "microsoft/scope", - event: { - inputs: { images: options.images ?? "all" }, - pull_request: { head: { repo: { full_name: options.headRepository ?? options.repository ?? "microsoft/scope" } } }, - }, - }, - needs: Object.fromEntries(Object.keys(workflow.jobs).map((name) => [name, { - result: options.results?.[name] ?? "success", - outputs: name === "detect-changes" ? options.changes ?? allChanges : {}, - }])), - }; -} - -function evaluate(expression: string, values: ReturnType, cancelled = false): data.ExpressionData { - const functions = new Map([ - ["always", { name: "always", minArgs: 0, maxArgs: 0, call: () => new data.BooleanData(true) }], - ["cancelled", { name: "cancelled", minArgs: 0, maxArgs: 0, call: () => new data.BooleanData(cancelled) }], - ]); - const { tokens } = new Lexer(expression.replace(/^\$\{\{|\}\}$/g, "").trim()).lex(); - const parsed = new Parser(tokens, Object.keys(values), [...functions.values()]).parse(); - const converted: unknown = JSON.parse(JSON.stringify(values), data.reviver); - if (!(converted instanceof data.Dictionary)) throw new Error("Expected an Actions context dictionary"); - return new Evaluator(parsed, converted, functions).evaluate(); -} - -function enabled(job: Job, values: ReturnType, cancelled = false): boolean { - if (!job.if) throw new Error("Expected an explicit job gate"); - return evaluate(job.if, values, cancelled).coerceString() === "true"; -} - -describe("canonical repository CI gates", () => { - for (const name of repositoryGated) { - const job = workflow.jobs[name]; - - it(`${name} runs only in microsoft/scope, never in fork repositories or the retired repository`, () => { - for (const repository of ["microsoft/scope", "growth-ecosystems/scope-core", "maintainer/scope"]) { - for (const event of ["push", "pull_request", "workflow_dispatch"]) { - expect(enabled(job, context({ repository, event }))).toBe(repository === "microsoft/scope"); - } - } - }); - - it(`${name} distinguishes upstream fork PRs from workflows executing in forks`, () => { - const upstreamForkPR = context({ - event: "pull_request", ref: "refs/pull/42/merge", headRepository: "contributor/fork", - }); - expect(enabled(job, upstreamForkPR)).toBe(name === "integration-test-queue"); - expect(enabled(job, context({ - event: "pull_request", ref: "refs/pull/42/merge", headRepository: "microsoft/scope", - }))).toBe(true); - }); - - it(`${name} preserves path and manual image selection gates`, () => { - expect(enabled(job, context({ changes: {} }))).toBe(false); - const publishing = name.startsWith("build-"); - expect(enabled(job, context({ event: "workflow_dispatch", changes: {}, images: "" }))).toBe(!publishing); - }); - } - - it("runs queue recovery on upstream fork PRs without credentials", () => { - const job = workflow.jobs["integration-test-queue"]; - for (const event of ["push", "pull_request", "workflow_dispatch"]) { - expect(enabled(job, context({ event, headRepository: "contributor/fork" }))).toBe(true); - } - for (const changed of ["shared", "scheduler"]) { - expect(enabled(job, context({ event: "pull_request", headRepository: "contributor/fork", changes: { [changed]: "true" } }))).toBe(true); - } - expect(enabled(job, context({ event: "pull_request", changes: {} }))).toBe(false); - expect(job.environment).toBeUndefined(); - expect(JSON.stringify(job)).not.toMatch(/secrets\.|docker\/login-action|azure\/login/); - }); - - it("grants OIDC only to publishers and no write token to public PR validation", () => { - expect(workflow.permissions).toEqual({ contents: "read" }); - expect(workflow.on).not.toHaveProperty("pull_request_target"); - for (const [name, job] of Object.entries(workflow.jobs)) { - expect(job["runs-on"]).toBe("ubuntu-latest"); - const publishing = ["build-images", "build-windows-image"].includes(name); - if (publishing) { - expect(job.permissions).toEqual({ contents: "read", "id-token": "write" }); - expect(job.environment).toBe("integration"); - } else { - expect(Object.values(job.permissions ?? {})).not.toContain("write"); - } - if (JSON.stringify(job).includes("secrets.") || publishing) { - expect(enabled(job, context({ event: "pull_request", headRepository: "contributor/fork" }))).toBe(false); - } - } - expect(workflow.jobs["detect-changes"].permissions).toEqual({ contents: "read", "pull-requests": "read" }); - }); - - it("preserves prerequisite failure and cancellation gates for publishing", () => { - for (const name of ["build-images", "build-windows-image"]) { - const job = workflow.jobs[name]; - for (const prerequisite of job.needs!.filter((dependency) => dependency !== "detect-changes")) { - for (const result of ["failure", "cancelled"]) { - expect(enabled(job, context({ results: { [prerequisite]: result } }))).toBe(false); - } - expect(enabled(job, context({ results: { [prerequisite]: "skipped" } }))).toBe(true); - } - expect(enabled(job, context(), true)).toBe(false); - } - }); - - it("has only current worker matrix entries with explicit test selection", () => { - const workers = workflow.jobs["integration-test"].strategy!.matrix.worker; - expect(workers).toHaveLength(2); - for (const worker of workers) { - expect(existsSync(worker.dockerfile)).toBe(true); - expect(existsSync(worker.versions_env)).toBe(true); - expect(existsSync(worker.test_pattern)).toBe(true); - expect(worker.images).toContain(`-f ${worker.dockerfile}`); - } - }); - - it("runs regression tests and queue tests when their CI configuration changes", () => { - const filterStep = workflow.jobs["detect-changes"].steps.find((step) => step.uses?.startsWith("dorny/paths-filter@")); - const filters: Record = parse(filterStep!.with!.filters); - expect(filters.typescript).toContain("scripts/ci-workflow.test.ts"); - expect(filters.typescript).toContain(".github/workflows/ci.yml"); - expect(filters.shared).toContain(".github/workflows/ci.yml"); - }); -}); - -describe("CI status aggregation", () => { - const summary = workflow.jobs["ci-summary"]; - const step = summary.steps[0]; - - function runSummary(values: ReturnType): number | null { - const script = step.run!.replace(/\$\{\{(.*?)\}\}/g, (_, expression: string) => - evaluate(expression, values).coerceString()); - const env = Object.fromEntries(Object.entries(step.env ?? {}).map(([name, expression]) => - [name, evaluate(expression, values).coerceString()])); - return spawnSync("bash", ["-e", "-c", script], { env, encoding: "utf8" }).status; - } - - it("accepts successful checks and intentionally disabled privileged checks", () => { - expect(summary.if).toBe("always()"); - expect(runSummary(context())).toBe(0); - expect(runSummary(context({ - event: "pull_request", - results: { "integration-test": "skipped", "llm-evals": "skipped" }, - }))).toBe(0); - }); - - for (const name of summary.needs!) { - it(`fails when ${name} fails or is cancelled`, () => { - for (const result of ["failure", "cancelled"]) { - expect(runSummary(context({ results: { [name]: result } }))).toBe(1); - } - }); - } - - it("does not relax required unit, lint, build, or license checks", () => { - for (const name of ["test", "lint", "build", "license-headers"]) { - expect(runSummary(context({ results: { [name]: "skipped" } }))).toBe(1); - } - }); -}); - -it("keeps all inline Bash scripts syntactically valid", () => { - for (const job of Object.values(workflow.jobs)) { - for (const step of job.steps) { - if (!step.run) continue; - const script = step.run.replace(/\$\{\{.*?\}\}/g, "placeholder"); - const result = spawnSync("bash", ["-n"], { input: script, encoding: "utf8" }); - expect(result.stderr, step.name).toBe(""); - expect(result.status, step.name).toBe(0); - } - } -}); From 0c5015bb8460eee21a7da13c5c50715b261c9372 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Mon, 28 Sep 2026 23:33:24 -0700 Subject: [PATCH 3/8] fix(ci): repair integration and image workflow execution Select integration checks for workflow edits, keep ACP tool checks credential-free on upstream fork PRs, make Docker Hub login optional, and repair demonstrated video, shell, and Windows path failures. Remove only obsolete matrix rows with no worker implementation; retain PR reporting and artifact uploads. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 79 +++++++++--------- CONTRIBUTING.md | 20 +++++ scripts/ci-workflow.test.ts | 159 ++++++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 38 deletions(-) create mode 100644 scripts/ci-workflow.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 398fe4fb..c0988f92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,6 @@ on: default: "" permissions: - id-token: write contents: read pull-requests: write issues: write @@ -154,8 +153,8 @@ jobs: name: LLM Evals needs: [detect-changes] if: > - github.event_name == 'workflow_dispatch' || - needs.detect-changes.outputs.evals == 'true' + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && + (github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.evals == 'true') runs-on: ubuntu-latest permissions: contents: read @@ -217,33 +216,22 @@ jobs: github.repository == 'microsoft/scope' && ( github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.ci == 'true' || needs.detect-changes.outputs.coder-acp-copilot == 'true' || needs.detect-changes.outputs.coder-acp-claude-code == 'true' || needs.detect-changes.outputs.shared == 'true' ) runs-on: ubuntu-latest + permissions: + contents: read + env: + TRUSTED_CODE: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + DOCKERHUB_LOGIN_ENABLED: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }} strategy: fail-fast: false matrix: worker: - - name: vscode-web - images: | - docker buildx build \ - --build-arg VSCODE_VERSION=$VSCODE_VERSION \ - --build-arg COPILOT_CHAT_VERSION=$COPILOT_CHAT_VERSION \ - --cache-from type=local,src=/tmp/.buildx-cache \ - --cache-to type=local,dest=/tmp/.buildx-cache-new,mode=min \ - --load \ - . - docker buildx build \ - --target dev \ - --build-arg VSCODE_VERSION=$VSCODE_VERSION \ - --build-arg COPILOT_CHAT_VERSION=$COPILOT_CHAT_VERSION \ - --cache-from type=local,src=/tmp/.buildx-cache \ - --cache-to type=local,dest=/tmp/.buildx-cache-new,mode=min \ - --load \ - . - name: copilot-acp dockerfile: apps/workers/coder-acp-copilot/Dockerfile versions_env: apps/workers/coder-acp-copilot/versions.env @@ -259,16 +247,6 @@ jobs: --cache-to type=local,dest=/tmp/.buildx-cache-new,mode=min \ --load \ . - - name: vscode-electron - images: | - docker buildx build \ - --target dev \ - --build-arg VSCODE_VERSION=$VSCODE_VERSION \ - --build-arg COPILOT_CHAT_VERSION=$COPILOT_CHAT_VERSION \ - --cache-from type=local,src=/tmp/.buildx-cache \ - --cache-to type=local,dest=/tmp/.buildx-cache-new,mode=min \ - --load \ - . - name: claude-code-acp dockerfile: apps/workers/coder-acp-claude-code/Dockerfile versions_env: apps/workers/coder-acp-claude-code/versions.env @@ -331,6 +309,7 @@ jobs: restore-keys: docker-${{ matrix.worker.name }}- - name: Log in to Docker Hub + if: env.TRUSTED_CODE == 'true' && env.DOCKERHUB_LOGIN_ENABLED == 'true' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -348,10 +327,10 @@ jobs: - name: Run integration tests run: npx vitest run --config vitest.integration.config.ts --silent=false ${{ matrix.worker.test_pattern }} env: - GH_AUTH_USERNAME: ${{ secrets.GH_AUTH_USERNAME }} - GH_AUTH_PASSWORD: ${{ secrets.GH_AUTH_PASSWORD }} - GH_AUTH_TOTP_SECRET: ${{ secrets.GH_AUTH_TOTP_SECRET }} - GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AUTH_USERNAME: ${{ env.TRUSTED_CODE == 'true' && secrets.GH_AUTH_USERNAME || '' }} + GH_AUTH_PASSWORD: ${{ env.TRUSTED_CODE == 'true' && secrets.GH_AUTH_PASSWORD || '' }} + GH_AUTH_TOTP_SECRET: ${{ env.TRUSTED_CODE == 'true' && secrets.GH_AUTH_TOTP_SECRET || '' }} + GITHUB_TOKEN: ${{ env.TRUSTED_CODE == 'true' && secrets.COPILOT_GITHUB_TOKEN || '' }} # NOTE: Claude Code credentials disabled — out of budget. # The expensive "completes coding prompts" test auto-skips when # these are absent; tool-check tests still run. @@ -383,7 +362,7 @@ jobs: if: always() run: | mkdir -p test-videos - i=0; find apps/workers/*/test-output -name "*.webm" | sort | while read f; do + i=0; find apps/workers -type f -path "*/test-output/*" -name "*.webm" | sort | while IFS= read -r f; do cp "$f" "test-videos/recording-$i.webm" i=$((i+1)) done @@ -407,10 +386,15 @@ jobs: github.repository == 'microsoft/scope' && ( github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.ci == 'true' || needs.detect-changes.outputs.shared == 'true' || needs.detect-changes.outputs.scheduler == 'true' ) runs-on: ubuntu-latest + permissions: + contents: read + env: + DOCKERHUB_LOGIN_ENABLED: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }} steps: - name: Checkout repository @@ -439,9 +423,12 @@ jobs: - name: Build shared packages run: pnpm --filter shared build - # mongo:7.0 and redis are pulled from Docker Hub; authenticate to avoid - # anonymous pull-rate limits. (Azurite comes from mcr.microsoft.com.) + # Authenticate when available on trusted code; fork PRs use public images + # anonymously so missing secrets do not prevent these local-service tests. - name: Log in to Docker Hub + if: >- + env.DOCKERHUB_LOGIN_ENABLED == 'true' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -755,6 +742,7 @@ jobs: detect-changes: runs-on: ubuntu-latest outputs: + ci: ${{ steps.changes.outputs.ci }} api: ${{ steps.changes.outputs.api }} judge: ${{ steps.changes.outputs.judge }} portal: ${{ steps.changes.outputs.portal }} @@ -782,6 +770,9 @@ jobs: with: base: ${{ github.event.before }} filters: | + ci: + - '.github/workflows/ci.yml' + - 'scripts/ci-workflow.test.ts' shared: - 'packages/shared/**' - 'package.json' @@ -820,6 +811,8 @@ jobs: - 'apps/workers/coder-acp-copilot-windows/**' - 'apps/workers/coder-acp-copilot/versions.env' typescript: + - '.github/workflows/ci.yml' + - 'scripts/ci-workflow.test.ts' - 'apps/**' - '!apps/gateway/**' - 'packages/**' @@ -844,6 +837,7 @@ jobs: always() && !cancelled() && github.repository == 'microsoft/scope' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (needs.test.result == 'success' || needs.test.result == 'skipped') && (needs.build.result == 'success' || needs.build.result == 'skipped') && (needs.gateway.result == 'success' || needs.gateway.result == 'skipped') && @@ -872,6 +866,9 @@ jobs: needs: [test, integration-test, integration-test-queue, build, gateway, detect-changes] runs-on: ubuntu-latest environment: integration + permissions: + contents: read + id-token: write strategy: fail-fast: false matrix: @@ -977,7 +974,9 @@ jobs: VERSION_PREFIX="copilot-${COPILOT_CLI_VERSION}" ;; coder-acp-claude-code) VERSION_PREFIX="claude-agent-acp-${CLAUDE_CODE_ACP_VERSION}-sdk-${CLAUDE_AGENT_SDK_VERSION}" ;; + vscode-web) VERSION_PREFIX="vscode-${VSCODE_VERSION}-copilot-${COPILOT_CHAT_VERSION}" ;; + vscode-electron) VERSION_PREFIX="vscode-electron-${VSCODE_VERSION}-copilot-${COPILOT_CHAT_VERSION}" ;; esac @@ -1052,6 +1051,7 @@ jobs: always() && !cancelled() && github.repository == 'microsoft/scope' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (needs.test.result == 'success' || needs.test.result == 'skipped') && ( (github.event_name == 'workflow_dispatch' && (github.event.inputs.images == 'all' || contains(github.event.inputs.images, 'coder-acp-copilot-windows'))) || @@ -1060,6 +1060,9 @@ jobs: needs: [test, detect-changes] runs-on: ubuntu-latest environment: integration + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v4 @@ -1123,7 +1126,7 @@ jobs: WORKER_DIR="apps/workers/coder-acp-copilot-windows" ACR_LOGIN_SERVER="${{ steps.base.outputs.acr_login_server }}" - DEPS_HASH=$(cat ${WORKER_DIR}/Dockerfile.deps ${WORKER_DIR}/versions.env ${WORKER_DIR}/Dockerfile.base | sha256sum | cut -c1-12) + DEPS_HASH=$(cat ${WORKER_DIR}/Dockerfile.deps apps/workers/coder-acp-copilot/versions.env ${WORKER_DIR}/Dockerfile.base | sha256sum | cut -c1-12) DEPS_TAG="deps-${DEPS_HASH}" DEPS_IMAGE="${ACR_LOGIN_SERVER}/scoped/coder-acp-copilot-windows-deps:${DEPS_TAG}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 691467f6..f397465e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,6 +119,26 @@ pnpm test:coverage # Unit tests with a coverage report pnpm test:integration # Integration tests (requires a .env file + Docker) ``` +CI's worker integration, queue recovery, and image-publishing jobs intentionally +require `github.repository == 'microsoft/scope'`: they do not execute in fork +repositories. A fork PR into upstream still runs the ACP workers' tool checks and +the disposable MongoDB/Redis/Azurite queue tests. Worker credentials are withheld +from fork-head code, so the existing live-auth tests skip when credentials are +absent. Docker Hub login is optional and restricted to trusted code; public images +can be pulled anonymously. LLM evals and Azure image publishing require trusted +PR heads; OIDC is granted only to the publishing jobs. PR test reporting and video +uploads remain enabled, with missing video directories treated as no recordings. + +Changes to the CI workflow select the integration checks through a dedicated +path filter without selecting every application image. Run the focused workflow +regressions with `pnpm exec vitest run scripts/ci-workflow.test.ts` and the real +queue tests with `pnpm test:integration:queue` (Docker required). +The ACP matrix covers the existing Copilot and Claude workers; the removed VS Code +workers have no Dockerfiles or integration suites in this repository. +Live-model credentials and the `integration` environment's Azure/OIDC/ACR +configuration remain maintainer prerequisites; passing public tests does not +validate cloud publishing or live-model access. + ## Build, lint, and typecheck ```bash diff --git a/scripts/ci-workflow.test.ts b/scripts/ci-workflow.test.ts new file mode 100644 index 00000000..eb4d39a5 --- /dev/null +++ b/scripts/ci-workflow.test.ts @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +// Reuse shared's existing YAML dependency, as the worker Dockerfiles do. +const { parse }: { parse: (source: string) => unknown } = + createRequire(resolve("packages/shared/package.json"))("yaml"); + +interface Step { + name?: string; + uses?: string; + run?: string; + if?: string; + env?: Record; + with?: Record; +} + +interface Job { + if?: string; + permissions?: Record; + env?: Record; + outputs?: Record; + steps: Step[]; + strategy?: { matrix: { worker: { name: string; dockerfile: string; versions_env: string; test_pattern: string; images: string }[] } }; +} + +const workflow = parse(readFileSync(".github/workflows/ci.yml", "utf8")) as { + on: Record; + permissions: Record; + jobs: Record; +}; +const jobs = workflow.jobs; +const directories: string[] = []; +const trusted = "github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository"; + +function step(job: string, name: string): Step { + const result = jobs[job].steps.find((candidate) => candidate.name === name); + if (!result) throw new Error(`Missing step ${job}: ${name}`); + return result; +} + +afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +describe("CI execution prerequisites", () => { + it("selects integration checks for workflow changes without marking every image changed", () => { + const filtersStep = jobs["detect-changes"].steps.find((candidate) => candidate.uses?.startsWith("dorny/paths-filter@")); + const filters = parse(filtersStep!.with!.filters) as Record; + expect(filters.ci).toContain(".github/workflows/ci.yml"); + expect(filters.ci).toContain("scripts/ci-workflow.test.ts"); + expect(filters.typescript).toContain("scripts/ci-workflow.test.ts"); + expect(filters.shared).not.toContain(".github/workflows/ci.yml"); + expect(jobs["detect-changes"].outputs?.ci).toBe("${{ steps.changes.outputs.ci }}"); + for (const name of ["integration-test", "integration-test-queue"]) { + expect(jobs[name].if).toContain("needs.detect-changes.outputs.ci == 'true'"); + } + }); + + it("targets each existing ACP worker explicitly instead of a missing root Dockerfile", () => { + const workers = jobs["integration-test"].strategy!.matrix.worker; + expect(workers.map((worker) => worker.name)).toEqual(["copilot-acp", "claude-code-acp"]); + for (const worker of workers) { + for (const path of [worker.dockerfile, worker.versions_env, worker.test_pattern]) { + expect(path).toBeTruthy(); + expect(existsSync(path), path).toBe(true); + } + expect(worker.images).toContain(`-f ${worker.dockerfile}`); + expect(readdirSync(join(worker.test_pattern, "src")).some((file) => file.endsWith(".integration.test.ts"))).toBe(true); + } + }); + + it("parses the inline shell scripts, including every image-tag case arm", () => { + for (const job of Object.values(jobs)) { + for (const command of job.steps.filter((candidate) => candidate.run)) { + const script = command.run!.replace(/\$\{\{.*?\}\}/g, "placeholder"); + const result = spawnSync("bash", ["-n"], { input: script, encoding: "utf8" }); + expect(result.stderr, command.name).toBe(""); + expect(result.status, command.name).toBe(0); + } + } + }); + + it("hashes the same existing Copilot versions file that Windows loads for its build", () => { + const load = step("build-windows-image", "Load pinned versions").run!; + const versionFile = load.match(/VERSION_FILE="([^"]+)"/)![1]; + expect(existsSync(versionFile)).toBe(true); + const hash = step("build-windows-image", "Resolve deps image tag").run!; + expect(hash).toContain(`Dockerfile.deps ${versionFile} \${WORKER_DIR}/Dockerfile.base`); + expect(hash).not.toContain("${WORKER_DIR}/versions.env"); + }); + + for (const recordings of [false, true]) { + it(`collects videos successfully ${recordings ? "with nested recordings and spaces in paths" : "without any test-output directories"}`, () => { + const directory = mkdtempSync(join(tmpdir(), "scope-ci-video-")); + directories.push(directory); + mkdirSync(join(directory, "apps/workers"), { recursive: true }); + if (recordings) { + const output = join(directory, "apps/workers/example/test-output/nested folder"); + mkdirSync(output, { recursive: true }); + writeFileSync(join(output, "first clip.webm"), "first"); + writeFileSync(join(output, "second.webm"), "second"); + } + const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", step("integration-test", "Collect test videos").run!], { + cwd: directory, encoding: "utf8", + }); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + const files = readdirSync(join(directory, "test-videos")); + expect(files).toHaveLength(recordings ? 2 : 0); + if (recordings) expect(files.map((file) => readFileSync(join(directory, "test-videos", file), "utf8")).sort()).toEqual(["first", "second"]); + expect(step("integration-test", "Upload test videos").with?.["if-no-files-found"]).toBe("ignore"); + }); + } +}); + +describe("CI repository and credential boundaries", () => { + it("retains all four canonical repository gates", () => { + for (const name of ["integration-test", "integration-test-queue", "build-images", "build-windows-image"]) { + expect(jobs[name].if).toContain("github.repository == 'microsoft/scope' &&"); + } + expect(workflow.on).not.toHaveProperty("pull_request_target"); + }); + + it("keeps ACP tool checks and queue tests available to upstream fork PRs with read-only tokens", () => { + for (const name of ["integration-test", "integration-test-queue"]) { + expect(jobs[name].permissions).toEqual({ contents: "read" }); + expect(jobs[name].if).not.toContain("head.repo"); + expect(jobs[name].env?.DOCKERHUB_LOGIN_ENABLED).toBe("${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}"); + expect(step(name, "Log in to Docker Hub").if).toContain("env.DOCKERHUB_LOGIN_ENABLED == 'true'"); + } + expect(step("integration-test-queue", "Log in to Docker Hub").if).toContain(trusted); + expect(jobs["integration-test"].env?.TRUSTED_CODE).toBe(`\${{ ${trusted} }}`); + expect(step("integration-test", "Log in to Docker Hub").if).toContain("env.TRUSTED_CODE == 'true'"); + for (const value of Object.values(step("integration-test", "Run integration tests").env!)) { + expect(value).toMatch(/^\$\{\{ env\.TRUSTED_CODE == 'true' && secrets\.\w+ \|\| '' \}\}$/); + } + }); + + it("reserves OIDC for trusted publishers while preserving PR reporting permissions and steps", () => { + expect(workflow.permissions).not.toHaveProperty("id-token"); + expect(workflow.permissions["pull-requests"]).toBe("write"); + expect(workflow.permissions.issues).toBe("write"); + for (const name of ["build-images", "build-windows-image"]) { + expect(jobs[name].if).toContain(trusted); + expect(jobs[name].permissions).toEqual({ contents: "read", "id-token": "write" }); + } + expect(jobs["llm-evals"].if).toContain(trusted); + for (const name of ["test", "gateway"]) { + expect(step(name, "Post test results to Pull Request").run).toContain("github-actions-ctrf pull-request"); + } + }); +}); From 74f20b4564659c35310a68a70cf121fde883aa65 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Mon, 28 Sep 2026 23:40:20 -0700 Subject: [PATCH 4/8] fix(ci): remove obsolete VS Code image tag branches Remove the obsolete VS Code assignments rather than retaining dead case arms. Current ACP tag behavior is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0988f92..89329634 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -974,10 +974,6 @@ jobs: VERSION_PREFIX="copilot-${COPILOT_CLI_VERSION}" ;; coder-acp-claude-code) VERSION_PREFIX="claude-agent-acp-${CLAUDE_CODE_ACP_VERSION}-sdk-${CLAUDE_AGENT_SDK_VERSION}" ;; - vscode-web) - VERSION_PREFIX="vscode-${VSCODE_VERSION}-copilot-${COPILOT_CHAT_VERSION}" ;; - vscode-electron) - VERSION_PREFIX="vscode-electron-${VSCODE_VERSION}-copilot-${COPILOT_CHAT_VERSION}" ;; esac if [[ "${{ github.event_name }}" == "pull_request" ]]; then From 0a577d7ec43f1c4f2182edf5359260e858f66025 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Mon, 28 Sep 2026 23:51:17 -0700 Subject: [PATCH 5/8] ci: separate public validation from internal automation Remove internal ACR and cross-repository publication/status automation from OSS while preserving local ACP builds, CLI validation, videos, reporting, Pages and maintenance workflows. Document legacy CLI distribution separately from OSS release publishing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/aw/actions-lock.json | 5 - .github/workflows/build-windows-base.yml | 113 -- .github/workflows/ci.yml | 368 ------ .github/workflows/daily-repo-status.lock.yml | 1020 ----------------- .github/workflows/daily-repo-status.md | 63 - .github/workflows/publish-cli.yml | 136 --- CONTRIBUTING.md | 26 +- .../Dockerfile.windows | 2 +- docs/architecture/cli-distribution.md | 55 +- scripts/ci-workflow.test.ts | 60 +- 10 files changed, 90 insertions(+), 1758 deletions(-) delete mode 100644 .github/workflows/build-windows-base.yml delete mode 100644 .github/workflows/daily-repo-status.lock.yml delete mode 100644 .github/workflows/daily-repo-status.md delete mode 100644 .github/workflows/publish-cli.yml diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index c3617028..cc2ded30 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -5,11 +5,6 @@ "version": "v8", "sha": "ed597411d8f924073f98dfc5c65a23a2325f34cd" }, - "github/gh-aw-actions/setup@v0.60.0": { - "repo": "github/gh-aw-actions/setup", - "version": "v0.60.0", - "sha": "998487a673ace02b3d9586e7511268089af88971" - }, "github/gh-aw-actions/setup@v0.63.0": { "repo": "github/gh-aw-actions/setup", "version": "v0.63.0", diff --git a/.github/workflows/build-windows-base.yml b/.github/workflows/build-windows-base.yml deleted file mode 100644 index b549af11..00000000 --- a/.github/workflows/build-windows-base.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: Build Windows Base Images - -on: - push: - branches: [main] - paths: - - "apps/workers/coder-acp-copilot-windows/Dockerfile.base" - - "apps/workers/coder-acp-copilot-windows/Dockerfile.deps" - - "apps/workers/coder-acp-copilot-windows/versions.env" - workflow_dispatch: - -permissions: - id-token: write - contents: read - -env: - WORKER_DIR: apps/workers/coder-acp-copilot-windows - -jobs: - build-base: - name: "Build base image" - runs-on: ubuntu-latest - environment: integration - outputs: - base_tag: ${{ steps.tags.outputs.base_tag }} - base_image: ${{ steps.tags.outputs.base_image }} - steps: - - uses: actions/checkout@v4 - - - name: Log in to Azure - uses: azure/login@v2 - with: - client-id: ${{ vars.AZURE_CLIENT_ID }} - tenant-id: ${{ vars.AZURE_TENANT_ID }} - subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - - - name: Compute base image tag - id: tags - run: | - HASH=$(sha256sum ${{ env.WORKER_DIR }}/Dockerfile.base | cut -c1-12) - BASE_TAG="base-${HASH}" - ACR_LOGIN_SERVER=$(az acr show --name "${{ vars.ACR_NAME }}" --resource-group "${{ vars.ACR_RESOURCE_GROUP }}" --query loginServer -o tsv) - BASE_IMAGE="${ACR_LOGIN_SERVER}/scoped/coder-windows-base:${BASE_TAG}" - echo "base_tag=${BASE_TAG}" >> "$GITHUB_OUTPUT" - echo "base_image=${BASE_IMAGE}" >> "$GITHUB_OUTPUT" - - - name: Check if base image already exists - id: check - run: | - if az acr repository show-tags --name "${{ vars.ACR_NAME }}" --repository scoped/coder-windows-base --query "[?@=='${{ steps.tags.outputs.base_tag }}']" -o tsv | grep -q .; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - - name: Build and push base image - if: steps.check.outputs.exists == 'false' - run: | - az acr build \ - --registry "${{ vars.ACR_NAME }}" \ - --resource-group "${{ vars.ACR_RESOURCE_GROUP }}" \ - --platform windows/amd64 \ - --image "scoped/coder-windows-base:${{ steps.tags.outputs.base_tag }}" \ - --image "scoped/coder-windows-base:latest" \ - --file "${{ env.WORKER_DIR }}/Dockerfile.base" \ - . - - build-deps: - name: "Build deps image" - needs: [build-base] - runs-on: ubuntu-latest - environment: integration - outputs: - deps_tag: ${{ steps.tags.outputs.deps_tag }} - deps_image: ${{ steps.tags.outputs.deps_image }} - steps: - - uses: actions/checkout@v4 - - - name: Log in to Azure - uses: azure/login@v2 - with: - client-id: ${{ vars.AZURE_CLIENT_ID }} - tenant-id: ${{ vars.AZURE_TENANT_ID }} - subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - - - name: Load pinned versions - id: versions - run: | - source ${{ env.WORKER_DIR }}/versions.env - echo "copilot_cli_version=${COPILOT_CLI_VERSION}" >> "$GITHUB_OUTPUT" - - - name: Compute deps image tag - id: tags - run: | - HASH=$(cat ${{ env.WORKER_DIR }}/Dockerfile.deps ${{ env.WORKER_DIR }}/versions.env ${{ env.WORKER_DIR }}/Dockerfile.base | sha256sum | cut -c1-12) - DEPS_TAG="deps-${HASH}" - ACR_LOGIN_SERVER=$(az acr show --name "${{ vars.ACR_NAME }}" --resource-group "${{ vars.ACR_RESOURCE_GROUP }}" --query loginServer -o tsv) - DEPS_IMAGE="${ACR_LOGIN_SERVER}/scoped/coder-acp-copilot-windows-deps:${DEPS_TAG}" - echo "deps_tag=${DEPS_TAG}" >> "$GITHUB_OUTPUT" - echo "deps_image=${DEPS_IMAGE}" >> "$GITHUB_OUTPUT" - - - name: Build and push deps image - run: | - az acr build \ - --registry "${{ vars.ACR_NAME }}" \ - --resource-group "${{ vars.ACR_RESOURCE_GROUP }}" \ - --platform windows/amd64 \ - --image "scoped/coder-acp-copilot-windows-deps:${{ steps.tags.outputs.deps_tag }}" \ - --image "scoped/coder-acp-copilot-windows-deps:latest" \ - --build-arg "BASE_IMAGE=${{ needs.build-base.outputs.base_image }}" \ - --build-arg "COPILOT_CLI_VERSION=${{ steps.versions.outputs.copilot_cli_version }}" \ - --file "${{ env.WORKER_DIR }}/Dockerfile.deps" \ - . diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89329634..257b0486 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,11 +30,6 @@ on: - "vitest.integration.config.ts" - ".github/workflows/ci.yml" workflow_dispatch: - inputs: - images: - description: 'Comma-separated list of images to build (or "all"). Leave empty to run CI only.' - required: false - default: "" permissions: contents: read @@ -828,369 +823,6 @@ jobs: - 'vitest.eval.config.ts' - 'packages/llm-eval/**' - # --------------------------------------------------------------------------- - # Build container images — gated on CI passing - # --------------------------------------------------------------------------- - build-images: - name: "Build image: ${{ matrix.image.name }}" - if: >- - always() && - !cancelled() && - github.repository == 'microsoft/scope' && - (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && - (needs.test.result == 'success' || needs.test.result == 'skipped') && - (needs.build.result == 'success' || needs.build.result == 'skipped') && - (needs.gateway.result == 'success' || needs.gateway.result == 'skipped') && - (needs.integration-test.result == 'success' || needs.integration-test.result == 'skipped') && - (needs.integration-test-queue.result == 'success' || needs.integration-test-queue.result == 'skipped') && - ( - (github.event_name == 'workflow_dispatch' && github.event.inputs.images != '') || - ((github.event_name == 'push' || github.event_name == 'pull_request') && ( - needs.detect-changes.outputs.api == 'true' || - needs.detect-changes.outputs.judge == 'true' || - needs.detect-changes.outputs.portal == 'true' || - needs.detect-changes.outputs.coder-acp-copilot == 'true' || - needs.detect-changes.outputs.coder-acp-claude-code == 'true' || - needs.detect-changes.outputs.token-manager == 'true' || - needs.detect-changes.outputs.model-scanner-copilot == 'true' || - needs.detect-changes.outputs.model-scanner-anthropic == 'true' || - needs.detect-changes.outputs.report-generator == 'true' || - needs.detect-changes.outputs.post-processor == 'true' || - needs.detect-changes.outputs.scheduler == 'true' || - needs.detect-changes.outputs.db-migrations == 'true' || - needs.detect-changes.outputs.gateway == 'true' || - needs.detect-changes.outputs.shared == 'true' || - needs.detect-changes.outputs.model-scanning == 'true' - )) - ) - needs: [test, integration-test, integration-test-queue, build, gateway, detect-changes] - runs-on: ubuntu-latest - environment: integration - permissions: - contents: read - id-token: write - strategy: - fail-fast: false - matrix: - image: - - name: api - dockerfile: apps/api/Dockerfile - changed: ${{ (github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.api == 'true' || needs.detect-changes.outputs.shared == 'true') && 'true' || 'false' }} - - name: judge - dockerfile: apps/judge/Dockerfile - changed: ${{ (github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.judge == 'true' || needs.detect-changes.outputs.shared == 'true') && 'true' || 'false' }} - - name: portal - dockerfile: apps/portal/Dockerfile - changed: ${{ (github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.portal == 'true' || needs.detect-changes.outputs.shared == 'true') && 'true' || 'false' }} - - name: coder-acp-copilot - dockerfile: apps/workers/coder-acp-copilot/Dockerfile - changed: ${{ (github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.coder-acp-copilot == 'true' || needs.detect-changes.outputs.shared == 'true') && 'true' || 'false' }} - - name: coder-acp-claude-code - dockerfile: apps/workers/coder-acp-claude-code/Dockerfile - changed: ${{ (github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.coder-acp-claude-code == 'true' || needs.detect-changes.outputs.shared == 'true') && 'true' || 'false' }} - - name: token-manager - dockerfile: apps/token-manager/Dockerfile - changed: ${{ (github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.token-manager == 'true' || needs.detect-changes.outputs.shared == 'true') && 'true' || 'false' }} - - name: model-scanner-copilot - dockerfile: apps/model-scanners/copilot/Dockerfile - changed: ${{ (github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.model-scanner-copilot == 'true' || needs.detect-changes.outputs.shared == 'true' || needs.detect-changes.outputs.model-scanning == 'true') && 'true' || 'false' }} - - name: model-scanner-anthropic - dockerfile: apps/model-scanners/anthropic/Dockerfile - changed: ${{ (github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.model-scanner-anthropic == 'true' || needs.detect-changes.outputs.shared == 'true' || needs.detect-changes.outputs.model-scanning == 'true') && 'true' || 'false' }} - - name: report-generator - dockerfile: apps/workers/report-generator/Dockerfile - changed: ${{ (github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.report-generator == 'true' || needs.detect-changes.outputs.shared == 'true') && 'true' || 'false' }} - - name: post-processor - dockerfile: apps/workers/post-processor/Dockerfile - changed: ${{ (github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.post-processor == 'true' || needs.detect-changes.outputs.shared == 'true') && 'true' || 'false' }} - - name: scheduler - dockerfile: apps/scheduler/Dockerfile - changed: ${{ (github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.scheduler == 'true' || needs.detect-changes.outputs.shared == 'true') && 'true' || 'false' }} - - name: db-migrations - dockerfile: packages/db-migrations/Dockerfile - changed: ${{ (github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.db-migrations == 'true') && 'true' || 'false' }} - - name: gateway - dockerfile: apps/gateway/Dockerfile - context: apps/gateway - target: runtime - changed: ${{ (github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.gateway == 'true') && 'true' || 'false' }} - exclude: - - image: - changed: "false" - steps: - - name: Check if image should be built (manual trigger) - id: check - if: github.event_name == 'workflow_dispatch' - run: | - IMAGES="${{ github.event.inputs.images }}" - if [[ "$IMAGES" == "all" ]] || echo "$IMAGES" | grep -qw "${{ matrix.image.name }}"; then - echo "skip=false" >> "$GITHUB_OUTPUT" - else - echo "skip=true" >> "$GITHUB_OUTPUT" - fi - - - uses: actions/checkout@v4 - if: steps.check.outputs.skip != 'true' - - - name: Log in to Azure - if: steps.check.outputs.skip != 'true' - uses: azure/login@v2 - with: - client-id: ${{ vars.AZURE_CLIENT_ID }} - tenant-id: ${{ vars.AZURE_TENANT_ID }} - subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - - - name: Log in to ACR - if: steps.check.outputs.skip != 'true' - run: az acr login --name "${{ vars.ACR_NAME }}" - - - name: Set up Docker Buildx - if: steps.check.outputs.skip != 'true' - uses: docker/setup-buildx-action@v3 - - - name: Compute image tags - id: tags - if: steps.check.outputs.skip != 'true' - run: | - ACR_LOGIN_SERVER=$(az acr show --name "${{ vars.ACR_NAME }}" --resource-group "${{ vars.ACR_RESOURCE_GROUP }}" --query loginServer -o tsv) - BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) - echo "build_time=${BUILD_TIME}" >> "$GITHUB_OUTPUT" - - # Load versions.env if present (needed for version-prefixed tags) - VERSION_PREFIX="" - for f in apps/workers/${{ matrix.image.name }}/versions.env apps/${{ matrix.image.name }}/versions.env; do - if [ -f "$f" ]; then - while IFS='=' read -r key value; do - [[ "$key" =~ ^#.*$ || -z "$key" ]] && continue - export "$key=$value" - done < "$f" - break - fi - done - - # Build version prefix from component versions - case "${{ matrix.image.name }}" in - coder-acp-copilot) - VERSION_PREFIX="copilot-${COPILOT_CLI_VERSION}" ;; - coder-acp-claude-code) - VERSION_PREFIX="claude-agent-acp-${CLAUDE_CODE_ACP_VERSION}-sdk-${CLAUDE_AGENT_SDK_VERSION}" ;; - esac - - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - GIT_SHA="${{ github.event.pull_request.head.sha }}" - TAGS="${ACR_LOGIN_SERVER}/scoped/${{ matrix.image.name }}:${GIT_SHA}" - TAGS="${TAGS},${ACR_LOGIN_SERVER}/scoped/${{ matrix.image.name }}:pr-${{ github.event.pull_request.number }}-latest" - else - GIT_SHA="${{ github.sha }}" - SHORT_SHA="${GIT_SHA:0:7}" - TIMESTAMP=$(date -u +%Y%m%dT%H%M%SZ) - TAGS="${ACR_LOGIN_SERVER}/scoped/${{ matrix.image.name }}:${GIT_SHA}" - TAGS="${TAGS},${ACR_LOGIN_SERVER}/scoped/${{ matrix.image.name }}:${TIMESTAMP}-${SHORT_SHA}" - if [ -n "$VERSION_PREFIX" ]; then - TAGS="${TAGS},${ACR_LOGIN_SERVER}/scoped/${{ matrix.image.name }}:${VERSION_PREFIX}-${TIMESTAMP}-${SHORT_SHA}" - fi - fi - - echo "git_sha=${GIT_SHA}" >> "$GITHUB_OUTPUT" - echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" - - - name: Load pinned versions - id: versions - if: steps.check.outputs.skip != 'true' - run: | - BUILD_ARGS="GIT_COMMIT=${{ steps.tags.outputs.git_sha }}" - BUILD_ARGS="${BUILD_ARGS}"$'\n'"BUILD_TIME=${{ steps.tags.outputs.build_time }}" - for f in apps/workers/${{ matrix.image.name }}/versions.env apps/${{ matrix.image.name }}/versions.env; do - if [ -f "$f" ]; then - while IFS='=' read -r key value; do - [[ "$key" =~ ^#.*$ || -z "$key" ]] && continue - BUILD_ARGS="${BUILD_ARGS}"$'\n'"${key}=${value}" - done < "$f" - break - fi - done - # Use EOF delimiter for multiline output - { - echo "build_args<> "$GITHUB_OUTPUT" - - - name: Build and push image - if: steps.check.outputs.skip != 'true' - uses: docker/build-push-action@v6 - with: - context: ${{ matrix.image.context || '.' }} - file: ${{ matrix.image.dockerfile }} - target: ${{ matrix.image.target || '' }} - push: true - tags: ${{ steps.tags.outputs.tags }} - build-args: | - ${{ steps.versions.outputs.build_args }} - VITE_AUTH_CLIENT_ID=${{ matrix.image.name == 'portal' && vars.VITE_AUTH_CLIENT_ID || '' }} - VITE_AUTH_AUTHORITY=${{ matrix.image.name == 'portal' && vars.VITE_AUTH_AUTHORITY || '' }} - VITE_AUTH_KNOWN_AUTHORITIES=${{ matrix.image.name == 'portal' && vars.VITE_AUTH_KNOWN_AUTHORITIES || '' }} - VITE_AUTH_SCOPES=${{ matrix.image.name == 'portal' && vars.VITE_AUTH_SCOPES || '' }} - VITE_AUTH_PROTOCOL_MODE=${{ matrix.image.name == 'portal' && vars.VITE_AUTH_PROTOCOL_MODE || '' }} - VITE_AUTH_REDIRECT_URI=${{ matrix.image.name == 'portal' && vars.VITE_AUTH_REDIRECT_URI || '' }} - VITE_AUTH_POST_LOGOUT_REDIRECT_URI=${{ matrix.image.name == 'portal' && vars.VITE_AUTH_POST_LOGOUT_REDIRECT_URI || '' }} - VITE_AUTH_CACHE_LOCATION=${{ matrix.image.name == 'portal' && vars.VITE_AUTH_CACHE_LOCATION || '' }} - cache-from: type=gha,scope=${{ matrix.image.name }},ignore-error=true - cache-to: type=gha,mode=max,scope=${{ matrix.image.name }} - - # --------------------------------------------------------------------------- - # Build Windows image — uses az acr build --platform windows/amd64 - # --------------------------------------------------------------------------- - build-windows-image: - name: "Build image: coder-acp-copilot-windows" - if: >- - always() && - !cancelled() && - github.repository == 'microsoft/scope' && - (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && - (needs.test.result == 'success' || needs.test.result == 'skipped') && - ( - (github.event_name == 'workflow_dispatch' && (github.event.inputs.images == 'all' || contains(github.event.inputs.images, 'coder-acp-copilot-windows'))) || - ((github.event_name == 'push' || github.event_name == 'pull_request') && (needs.detect-changes.outputs.coder-acp-copilot-windows == 'true' || needs.detect-changes.outputs.shared == 'true')) - ) - needs: [test, detect-changes] - runs-on: ubuntu-latest - environment: integration - permissions: - contents: read - id-token: write - steps: - - uses: actions/checkout@v4 - - - name: Log in to Azure - uses: azure/login@v2 - with: - client-id: ${{ vars.AZURE_CLIENT_ID }} - tenant-id: ${{ vars.AZURE_TENANT_ID }} - subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - - - name: Load pinned versions - id: versions - run: | - EXTRA_ARGS="" - VERSION_FILE="apps/workers/coder-acp-copilot/versions.env" - if [ -f "$VERSION_FILE" ]; then - while IFS='=' read -r key value; do - [[ "$key" =~ ^#.*$ || -z "$key" ]] && continue - export "$key=$value" - EXTRA_ARGS="${EXTRA_ARGS} --build-arg ${key}=${value}" - done < "$VERSION_FILE" - fi - echo "extra_args=${EXTRA_ARGS}" >> "$GITHUB_OUTPUT" - echo "copilot_version=${COPILOT_CLI_VERSION}" >> "$GITHUB_OUTPUT" - - - name: Resolve base image tag - id: base - run: | - WORKER_DIR="apps/workers/coder-acp-copilot-windows" - ACR_LOGIN_SERVER=$(az acr show --name "${{ vars.ACR_NAME }}" --resource-group "${{ vars.ACR_RESOURCE_GROUP }}" --query loginServer -o tsv) - echo "acr_login_server=${ACR_LOGIN_SERVER}" >> "$GITHUB_OUTPUT" - - BASE_HASH=$(sha256sum ${WORKER_DIR}/Dockerfile.base | cut -c1-12) - BASE_TAG="base-${BASE_HASH}" - - if az acr repository show-tags --name "${{ vars.ACR_NAME }}" --repository scoped/coder-windows-base --query "[?@=='${BASE_TAG}']" -o tsv | grep -q .; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - echo "base_tag=${BASE_TAG}" >> "$GITHUB_OUTPUT" - - - name: Build base image (if not in ACR) - if: steps.base.outputs.exists == 'false' - run: | - WORKER_DIR="apps/workers/coder-acp-copilot-windows" - - az acr build \ - --registry "${{ vars.ACR_NAME }}" \ - --resource-group "${{ vars.ACR_RESOURCE_GROUP }}" \ - --platform windows/amd64 \ - --image "scoped/coder-windows-base:${{ steps.base.outputs.base_tag }}" \ - --image "scoped/coder-windows-base:latest" \ - --file "${WORKER_DIR}/Dockerfile.base" \ - . - - - name: Resolve deps image tag - id: deps - run: | - WORKER_DIR="apps/workers/coder-acp-copilot-windows" - ACR_LOGIN_SERVER="${{ steps.base.outputs.acr_login_server }}" - - DEPS_HASH=$(cat ${WORKER_DIR}/Dockerfile.deps apps/workers/coder-acp-copilot/versions.env ${WORKER_DIR}/Dockerfile.base | sha256sum | cut -c1-12) - DEPS_TAG="deps-${DEPS_HASH}" - DEPS_IMAGE="${ACR_LOGIN_SERVER}/scoped/coder-acp-copilot-windows-deps:${DEPS_TAG}" - - if az acr repository show-tags --name "${{ vars.ACR_NAME }}" --repository scoped/coder-acp-copilot-windows-deps --query "[?@=='${DEPS_TAG}']" -o tsv | grep -q .; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - echo "deps_tag=${DEPS_TAG}" >> "$GITHUB_OUTPUT" - echo "image=${DEPS_IMAGE}" >> "$GITHUB_OUTPUT" - - - name: Build deps image (if not in ACR) - if: steps.deps.outputs.exists == 'false' - run: | - WORKER_DIR="apps/workers/coder-acp-copilot-windows" - ACR_LOGIN_SERVER="${{ steps.base.outputs.acr_login_server }}" - BASE_IMAGE="${ACR_LOGIN_SERVER}/scoped/coder-windows-base:${{ steps.base.outputs.base_tag }}" - - az acr build \ - --registry "${{ vars.ACR_NAME }}" \ - --resource-group "${{ vars.ACR_RESOURCE_GROUP }}" \ - --platform windows/amd64 \ - --image "scoped/coder-acp-copilot-windows-deps:${{ steps.deps.outputs.deps_tag }}" \ - --image "scoped/coder-acp-copilot-windows-deps:latest" \ - --build-arg "BASE_IMAGE=${BASE_IMAGE}" \ - --build-arg "COPILOT_CLI_VERSION=${{ steps.versions.outputs.copilot_version }}" \ - --file "${WORKER_DIR}/Dockerfile.deps" \ - . - - - name: Compute image tags - id: tags - run: | - BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) - echo "build_time=${BUILD_TIME}" >> "$GITHUB_OUTPUT" - - VERSION_PREFIX="copilot-${{ steps.versions.outputs.copilot_version }}" - - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - GIT_SHA="${{ github.event.pull_request.head.sha }}" - IMAGE_ARGS="--image scoped/coder-acp-copilot-windows:${GIT_SHA}" - IMAGE_ARGS="${IMAGE_ARGS} --image scoped/coder-acp-copilot-windows:pr-${{ github.event.pull_request.number }}-latest" - else - GIT_SHA="${{ github.sha }}" - SHORT_SHA="${GIT_SHA:0:7}" - TIMESTAMP=$(date -u +%Y%m%dT%H%M%SZ) - IMAGE_ARGS="--image scoped/coder-acp-copilot-windows:${GIT_SHA}" - IMAGE_ARGS="${IMAGE_ARGS} --image scoped/coder-acp-copilot-windows:${TIMESTAMP}-${SHORT_SHA}" - IMAGE_ARGS="${IMAGE_ARGS} --image scoped/coder-acp-copilot-windows:${VERSION_PREFIX}-${TIMESTAMP}-${SHORT_SHA}" - fi - - echo "git_sha=${GIT_SHA}" >> "$GITHUB_OUTPUT" - echo "image_args=${IMAGE_ARGS}" >> "$GITHUB_OUTPUT" - - - name: Build and push to ACR (Windows) - run: | - az acr build \ - --registry "${{ vars.ACR_NAME }}" \ - --resource-group "${{ vars.ACR_RESOURCE_GROUP }}" \ - --platform windows/amd64 \ - ${{ steps.tags.outputs.image_args }} \ - --build-arg DEPS_IMAGE=${{ steps.deps.outputs.image }} \ - --build-arg GIT_COMMIT=${{ steps.tags.outputs.git_sha }} \ - --build-arg BUILD_TIME=${{ steps.tags.outputs.build_time }} \ - ${{ steps.versions.outputs.extra_args }} \ - --file apps/workers/coder-acp-copilot-windows/Dockerfile.windows \ - . - # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- diff --git a/.github/workflows/daily-repo-status.lock.yml b/.github/workflows/daily-repo-status.lock.yml deleted file mode 100644 index c83fdb95..00000000 --- a/.github/workflows/daily-repo-status.lock.yml +++ /dev/null @@ -1,1020 +0,0 @@ -# -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ -# | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ -# \_| |_/\__, |\___|_| |_|\__|_|\___| -# __/ | -# _ _ |___/ -# | | | | / _| | -# | | | | ___ _ __ _ __| |_| | _____ ____ -# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| -# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ -# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ -# -# This file was automatically generated by gh-aw (v0.60.0). DO NOT EDIT. -# -# To update this file, edit githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f and run: -# gh aw compile -# Not all edits will cause changes to this file. -# -# For more information: https://github.github.com/gh-aw/introduction/overview/ -# -# This workflow creates daily repo status reports. It gathers recent repository -# activity (issues, PRs, discussions, releases, code changes) and generates -# engaging GitHub issues with productivity insights, community highlights, -# and project recommendations. -# -# Source: githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f -# -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"f11e23adeb5079f5ebf793637f305ba3386f862152fff2f1c8eec0e0533b7905","compiler_version":"v0.60.0","strict":true} - -name: "Daily Repo Status" -"on": - schedule: - - cron: "47 23 * * *" - workflow_dispatch: - -permissions: {} - -concurrency: - group: "gh-aw-${{ github.workflow }}" - -run-name: "Daily Repo Status" - -jobs: - activation: - runs-on: ubuntu-slim - permissions: - contents: read - outputs: - comment_id: "" - comment_repo: "" - model: ${{ steps.generate_aw_info.outputs.model }} - secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} - steps: - - name: Setup Scripts - uses: github/gh-aw-actions/setup@998487a673ace02b3d9586e7511268089af88971 # v0.60.0 - with: - destination: /opt/gh-aw/actions - - name: Generate agentic run info - id: generate_aw_info - env: - GH_AW_INFO_ENGINE_ID: "copilot" - GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" - GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} - GH_AW_INFO_VERSION: "" - GH_AW_INFO_AGENT_VERSION: "latest" - GH_AW_INFO_CLI_VERSION: "v0.60.0" - GH_AW_INFO_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_INFO_EXPERIMENTAL: "false" - GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" - GH_AW_INFO_STAGED: "false" - GH_AW_INFO_ALLOWED_DOMAINS: '["defaults"]' - GH_AW_INFO_FIREWALL_ENABLED: "true" - GH_AW_INFO_AWF_VERSION: "v0.24.2" - GH_AW_INFO_AWMG_VERSION: "" - GH_AW_INFO_FIREWALL_TYPE: "squid" - GH_AW_COMPILED_STRICT: "true" - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { main } = require('/opt/gh-aw/actions/generate_aw_info.cjs'); - await main(core, context); - - name: Validate COPILOT_GITHUB_TOKEN secret - id: validate-secret - run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default - env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - - name: Checkout .github and .agents folders - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - sparse-checkout: | - .github - .agents - sparse-checkout-cone-mode: true - fetch-depth: 1 - - name: Check workflow file timestamps - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_WORKFLOW_FILE: "daily-repo-status.lock.yml" - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); - await main(); - - name: Create prompt with built-in context - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - run: | - bash /opt/gh-aw/actions/create_prompt_first.sh - { - cat << 'GH_AW_PROMPT_EOF' - - GH_AW_PROMPT_EOF - cat "/opt/gh-aw/prompts/xpia.md" - cat "/opt/gh-aw/prompts/temp_folder_prompt.md" - cat "/opt/gh-aw/prompts/markdown.md" - cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_EOF' - - Tools: create_issue, missing_tool, missing_data, noop - - - The following GitHub context information is available for this workflow: - {{#if __GH_AW_GITHUB_ACTOR__ }} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if __GH_AW_GITHUB_REPOSITORY__ }} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if __GH_AW_GITHUB_WORKSPACE__ }} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} - - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} - - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} - - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} - - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ - {{/if}} - {{#if __GH_AW_GITHUB_RUN_ID__ }} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - GH_AW_PROMPT_EOF - cat "/opt/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_EOF' - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' - {{#runtime-import .github/workflows/daily-repo-status.md}} - GH_AW_PROMPT_EOF - } > "$GH_AW_PROMPT" - - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); - await main(); - - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - - const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - - // Call the substitution function - return await substitutePlaceholders({ - file: process.env.GH_AW_PROMPT, - substitutions: { - GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, - GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, - GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, - GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, - GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE - } - }); - - name: Validate prompt placeholders - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh - - name: Print prompt - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/print_prompt_summary.sh - - name: Upload activation artifact - if: success() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: activation - path: | - /tmp/gh-aw/aw_info.json - /tmp/gh-aw/aw-prompts/prompt.txt - retention-days: 1 - - agent: - needs: activation - runs-on: ubuntu-latest - permissions: - contents: read - issues: read - pull-requests: read - concurrency: - group: "gh-aw-copilot-${{ github.workflow }}" - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - GH_AW_ASSETS_ALLOWED_EXTS: "" - GH_AW_ASSETS_BRANCH: "" - GH_AW_ASSETS_MAX_SIZE_KB: 0 - GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs - GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl - GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json - GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json - GH_AW_WORKFLOW_ID_SANITIZED: dailyrepostatus - outputs: - checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} - detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} - detection_success: ${{ steps.detection_conclusion.outputs.success }} - has_patch: ${{ steps.collect_output.outputs.has_patch }} - inference_access_error: ${{ steps.detect-inference-error.outputs.inference_access_error || 'false' }} - model: ${{ needs.activation.outputs.model }} - output: ${{ steps.collect_output.outputs.output }} - output_types: ${{ steps.collect_output.outputs.output_types }} - steps: - - name: Setup Scripts - uses: github/gh-aw-actions/setup@998487a673ace02b3d9586e7511268089af88971 # v0.60.0 - with: - destination: /opt/gh-aw/actions - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Create gh-aw temp directory - run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git config --global am.keepcr true - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Checkout PR branch - id: checkout-pr - if: | - (github.event.pull_request) || (github.event.issue.pull_request) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); - await main(); - - name: Install GitHub Copilot CLI - run: /opt/gh-aw/actions/install_copilot_cli.sh latest - env: - GH_HOST: github.com - - name: Install AWF binary - run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.24.2 - - name: Generate GitHub App token - id: github-mcp-app-token - uses: actions/create-github-app-token@a7f885bf4560200d03183ed941cb6fb072e4b343 # v3.0.0-beta.4 - with: - app-id: ${{ secrets.GH_AG_APP_ID }} - private-key: ${{ secrets.GH_AG_APP_PRIVATE_KEY }} - owner: growth-ecosystems - repositories: |- - scope-core - scope-core-infra - github-api-url: ${{ github.api_url }} - permission-contents: read - permission-issues: read - permission-pull-requests: read - - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.2 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.2 ghcr.io/github/gh-aw-firewall/squid:0.24.2 ghcr.io/github/gh-aw-mcpg:v0.1.15 node:lts-alpine - - name: Write Safe Outputs Config - run: | - mkdir -p /opt/gh-aw/safeoutputs - mkdir -p /tmp/gh-aw/safeoutputs - mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' - {"create_issue":{"max":1},"mentions":{"enabled":false},"missing_data":{},"missing_tool":{},"noop":{"max":1}} - GH_AW_SAFE_OUTPUTS_CONFIG_EOF - - name: Write Safe Outputs Tools - run: | - cat > /opt/gh-aw/safeoutputs/tools_meta.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_META_EOF' - { - "description_suffixes": { - "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[repo-status] \". Labels [\"agentic-workflows\"] will be automatically added." - }, - "repo_params": {}, - "dynamic_tools": [] - } - GH_AW_SAFE_OUTPUTS_TOOLS_META_EOF - cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' - { - "create_issue": { - "defaultMax": 1, - "fields": { - "body": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - }, - "labels": { - "type": "array", - "itemType": "string", - "itemSanitize": true, - "itemMaxLength": 128 - }, - "parent": { - "issueOrPRNumber": true - }, - "repo": { - "type": "string", - "maxLength": 256 - }, - "temporary_id": { - "type": "string" - }, - "title": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 128 - } - } - }, - "missing_data": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "context": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "data_type": { - "type": "string", - "sanitize": true, - "maxLength": 128 - }, - "reason": { - "type": "string", - "sanitize": true, - "maxLength": 256 - } - } - }, - "missing_tool": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 512 - }, - "reason": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "tool": { - "type": "string", - "sanitize": true, - "maxLength": 128 - } - } - }, - "noop": { - "defaultMax": 1, - "fields": { - "message": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - } - } - } - } - GH_AW_SAFE_OUTPUTS_VALIDATION_EOF - node /opt/gh-aw/actions/generate_safe_outputs_tools.cjs - - name: Generate Safe Outputs MCP Server Config - id: safe-outputs-config - run: | - # Generate a secure random API key (360 bits of entropy, 40+ chars) - # Mask immediately to prevent timing vulnerabilities - API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${API_KEY}" - - PORT=3001 - - # Set outputs for next steps - { - echo "safe_outputs_api_key=${API_KEY}" - echo "safe_outputs_port=${PORT}" - } >> "$GITHUB_OUTPUT" - - echo "Safe Outputs MCP server will run on port ${PORT}" - - - name: Start Safe Outputs MCP HTTP Server - id: safe-outputs-start - env: - DEBUG: '*' - GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} - GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} - GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json - GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json - GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs - run: | - # Environment variables are set above to prevent template injection - export DEBUG - export GH_AW_SAFE_OUTPUTS_PORT - export GH_AW_SAFE_OUTPUTS_API_KEY - export GH_AW_SAFE_OUTPUTS_TOOLS_PATH - export GH_AW_SAFE_OUTPUTS_CONFIG_PATH - export GH_AW_MCP_LOG_DIR - - bash /opt/gh-aw/actions/start_safe_outputs_server.sh - - - name: Start MCP Gateway - id: start-mcp-gateway - env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} - GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} - GITHUB_MCP_SERVER_TOKEN: ${{ steps.github-mcp-app-token.outputs.token }} - run: | - set -eo pipefail - mkdir -p /tmp/gh-aw/mcp-config - - # Export gateway environment variables for MCP config and gateway script - export MCP_GATEWAY_PORT="80" - export MCP_GATEWAY_DOMAIN="host.docker.internal" - MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${MCP_GATEWAY_API_KEY}" - export MCP_GATEWAY_API_KEY - export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" - mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" - export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" - export DEBUG="*" - - export GH_AW_ENGINE="copilot" - export GITHUB_PERSONAL_ACCESS_TOKEN="$GITHUB_MCP_SERVER_TOKEN" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_PERSONAL_ACCESS_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.15' - - mkdir -p /home/runner/.copilot - cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh - { - "mcpServers": { - "github": { - "type": "http", - "url": "https://api.githubcopilot.com/mcp/", - "headers": { - "Authorization": "Bearer \${GITHUB_PERSONAL_ACCESS_TOKEN}", - "X-MCP-Readonly": "true", - "X-MCP-Toolsets": "repos,issues,pull_requests" - }, - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", - "GITHUB_HOST": "\${GITHUB_SERVER_URL}" - } - }, - "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", - "headers": { - "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" - } - } - }, - "gateway": { - "port": $MCP_GATEWAY_PORT, - "domain": "${MCP_GATEWAY_DOMAIN}", - "apiKey": "${MCP_GATEWAY_API_KEY}", - "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" - } - } - GH_AW_MCP_CONFIG_EOF - - name: Download activation artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: activation - path: /tmp/gh-aw - - name: Clean git credentials - continue-on-error: true - run: bash /opt/gh-aw/actions/clean_git_credentials.sh - - name: Execute GitHub Copilot CLI - id: agentic_execution - # Copilot CLI tool arguments (sorted): - timeout-minutes: 20 - run: | - set -o pipefail - touch /tmp/gh-aw/agent-step-summary.md - # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.24.2 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log - env: - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} - GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json - GH_AW_PHASE: agent - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_VERSION: v0.60.0 - GITHUB_API_URL: ${{ github.api_url }} - GITHUB_AW: true - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_MCP_SERVER_TOKEN: ${{ steps.github-mcp-app-token.outputs.token }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md - GITHUB_WORKSPACE: ${{ github.workspace }} - GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_AUTHOR_NAME: github-actions[bot] - GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_COMMITTER_NAME: github-actions[bot] - XDG_CONFIG_HOME: /home/runner - - name: Detect inference access error - id: detect-inference-error - if: always() - continue-on-error: true - run: bash /opt/gh-aw/actions/detect_inference_access_error.sh - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git config --global am.keepcr true - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Copy Copilot session state files to logs - if: always() - continue-on-error: true - run: | - # Copy Copilot session state files to logs folder for artifact collection - # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them - SESSION_STATE_DIR="$HOME/.copilot/session-state" - LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - - if [ -d "$SESSION_STATE_DIR" ]; then - echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" - mkdir -p "$LOGS_DIR" - cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true - echo "Session state files copied successfully" - else - echo "No session-state directory found at $SESSION_STATE_DIR" - fi - - name: Stop MCP Gateway - if: always() - continue-on-error: true - env: - MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} - MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} - GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} - run: | - bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" - - name: Redact secrets in logs - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); - await main(); - env: - GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AG_APP_ID,GH_AG_APP_PRIVATE_KEY,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' - SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - SECRET_GH_AG_APP_ID: ${{ secrets.GH_AG_APP_ID }} - SECRET_GH_AG_APP_PRIVATE_KEY: ${{ secrets.GH_AG_APP_PRIVATE_KEY }} - SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} - SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Append agent step summary - if: always() - run: bash /opt/gh-aw/actions/append_agent_step_summary.sh - - name: Copy Safe Outputs - if: always() - run: | - mkdir -p /tmp/gh-aw - cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true - - name: Ingest agent output - id: collect_output - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" - GH_AW_ALLOWED_GITHUB_REFS: "" - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_API_URL: ${{ github.api_url }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs'); - await main(); - - name: Parse agent logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_copilot_log.cjs'); - await main(); - - name: Parse MCP Gateway logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); - await main(); - - name: Print firewall logs - if: always() - continue-on-error: true - env: - AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs - run: | - # Fix permissions on firewall logs so they can be uploaded as artifacts - # AWF runs with sudo, creating files owned by root - sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true - # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) - if command -v awf &> /dev/null; then - awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" - else - echo 'AWF binary not installed, skipping firewall log summary' - fi - - name: Upload agent artifacts - if: always() - continue-on-error: true - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: agent - path: | - /tmp/gh-aw/aw-prompts/prompt.txt - /tmp/gh-aw/sandbox/agent/logs/ - /tmp/gh-aw/redacted-urls.log - /tmp/gh-aw/mcp-logs/ - /tmp/gh-aw/sandbox/firewall/logs/ - /tmp/gh-aw/agent-stdio.log - /tmp/gh-aw/agent/ - /tmp/gh-aw/safeoutputs.jsonl - /tmp/gh-aw/agent_output.json - if-no-files-found: ignore - # --- Threat Detection (inline) --- - - name: Check if detection needed - id: detection_guard - if: always() - env: - OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }} - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} - run: | - if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then - echo "run_detection=true" >> "$GITHUB_OUTPUT" - echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" - else - echo "run_detection=false" >> "$GITHUB_OUTPUT" - echo "Detection skipped: no agent outputs or patches to analyze" - fi - - name: Clear MCP configuration for detection - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - rm -f /tmp/gh-aw/mcp-config/mcp-servers.json - rm -f /home/runner/.copilot/mcp-config.json - rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" - - name: Prepare threat detection files - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - mkdir -p /tmp/gh-aw/threat-detection/aw-prompts - cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true - cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true - for f in /tmp/gh-aw/aw-*.patch; do - [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - done - echo "Prepared threat detection files:" - ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true - - name: Setup threat detection - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - WORKFLOW_NAME: "Daily Repo Status" - WORKFLOW_DESCRIPTION: "This workflow creates daily repo status reports. It gathers recent repository\nactivity (issues, PRs, discussions, releases, code changes) and generates\nengaging GitHub issues with productivity insights, community highlights,\nand project recommendations." - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs'); - await main(); - - name: Ensure threat-detection directory and log - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - mkdir -p /tmp/gh-aw/threat-detection - touch /tmp/gh-aw/threat-detection/detection.log - - name: Execute GitHub Copilot CLI - if: always() && steps.detection_guard.outputs.run_detection == 'true' - id: detection_agentic_execution - # Copilot CLI tool arguments (sorted): - # --allow-tool shell(cat) - # --allow-tool shell(grep) - # --allow-tool shell(head) - # --allow-tool shell(jq) - # --allow-tool shell(ls) - # --allow-tool shell(tail) - # --allow-tool shell(wc) - timeout-minutes: 20 - run: | - set -o pipefail - touch /tmp/gh-aw/agent-step-summary.md - # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.24.2 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log - env: - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} - GH_AW_PHASE: detection - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_VERSION: v0.60.0 - GITHUB_API_URL: ${{ github.api_url }} - GITHUB_AW: true - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md - GITHUB_WORKSPACE: ${{ github.workspace }} - GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_AUTHOR_NAME: github-actions[bot] - GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_COMMITTER_NAME: github-actions[bot] - XDG_CONFIG_HOME: /home/runner - - name: Parse threat detection results - id: parse_detection_results - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs'); - await main(); - - name: Upload threat detection log - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: detection - path: /tmp/gh-aw/threat-detection/detection.log - if-no-files-found: ignore - - name: Set detection conclusion - id: detection_conclusion - if: always() - env: - RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} - DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }} - run: | - if [[ "$RUN_DETECTION" != "true" ]]; then - echo "conclusion=skipped" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection was not needed, marking as skipped" - elif [[ "$DETECTION_SUCCESS" == "true" ]]; then - echo "conclusion=success" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection passed successfully" - else - echo "conclusion=failure" >> "$GITHUB_OUTPUT" - echo "success=false" >> "$GITHUB_OUTPUT" - echo "Detection found issues" - fi - - name: Invalidate GitHub App token - if: always() && steps.github-mcp-app-token.outputs.token != '' - env: - TOKEN: ${{ steps.github-mcp-app-token.outputs.token }} - run: | - echo "Revoking GitHub App installation token..." - # GitHub CLI will auth with the token being revoked. - gh api \ - --method DELETE \ - -H "Authorization: token $TOKEN" \ - /installation/token || echo "Token revoke may already be expired." - - echo "Token invalidation step complete." - - conclusion: - needs: - - activation - - agent - - safe_outputs - if: (always()) && (needs.agent.result != 'skipped') - runs-on: ubuntu-slim - permissions: - contents: read - issues: write - concurrency: - group: "gh-aw-conclusion-daily-repo-status" - cancel-in-progress: false - outputs: - noop_message: ${{ steps.noop.outputs.noop_message }} - tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} - total_count: ${{ steps.missing_tool.outputs.total_count }} - steps: - - name: Setup Scripts - uses: github/gh-aw-actions/setup@998487a673ace02b3d9586e7511268089af88971 # v0.60.0 - with: - destination: /opt/gh-aw/actions - - name: Download agent output artifact - id: download-agent-output - continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: agent - path: /tmp/gh-aw/ - - name: Setup agent output environment variable - if: steps.download-agent-output.outcome == 'success' - run: | - mkdir -p /tmp/gh-aw/ - find "/tmp/gh-aw/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" - - name: Process No-Op Messages - id: noop - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_NOOP_MAX: "1" - GH_AW_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/346204513ecfa08b81566450d7d599556807389f/workflows/daily-repo-status.md" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/noop.cjs'); - await main(); - - name: Record Missing Tool - id: missing_tool - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/346204513ecfa08b81566450d7d599556807389f/workflows/daily-repo-status.md" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/missing_tool.cjs'); - await main(); - - name: Handle Agent Failure - id: handle_agent_failure - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/346204513ecfa08b81566450d7d599556807389f/workflows/daily-repo-status.md" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_WORKFLOW_ID: "daily-repo-status" - GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} - GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} - GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} - GH_AW_GROUP_REPORTS: "false" - GH_AW_FAILURE_REPORT_AS_ISSUE: "true" - GH_AW_TIMEOUT_MINUTES: "20" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs'); - await main(); - - name: Handle No-Op Message - id: handle_noop_message - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/346204513ecfa08b81566450d7d599556807389f/workflows/daily-repo-status.md" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} - GH_AW_NOOP_REPORT_AS_ISSUE: "true" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs'); - await main(); - - safe_outputs: - needs: agent - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') - runs-on: ubuntu-slim - permissions: - contents: read - issues: write - timeout-minutes: 15 - env: - GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/daily-repo-status" - GH_AW_ENGINE_ID: "copilot" - GH_AW_WORKFLOW_ID: "daily-repo-status" - GH_AW_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/346204513ecfa08b81566450d7d599556807389f/workflows/daily-repo-status.md" - outputs: - code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} - code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} - create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} - create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} - created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }} - created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }} - process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} - process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} - steps: - - name: Setup Scripts - uses: github/gh-aw-actions/setup@998487a673ace02b3d9586e7511268089af88971 # v0.60.0 - with: - destination: /opt/gh-aw/actions - - name: Download agent output artifact - id: download-agent-output - continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: agent - path: /tmp/gh-aw/ - - name: Setup agent output environment variable - if: steps.download-agent-output.outcome == 'success' - run: | - mkdir -p /tmp/gh-aw/ - find "/tmp/gh-aw/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" - - name: Process Safe Outputs - id: process_safe_outputs - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"close_older_issues\":true,\"labels\":[\"agentic-workflows\"],\"max\":1,\"title_prefix\":\"[repo-status] \"},\"missing_data\":{},\"missing_tool\":{}}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); - await main(); - - name: Upload Safe Output Items Manifest - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: safe-output-items - path: /tmp/safe-output-items.jsonl - if-no-files-found: warn - diff --git a/.github/workflows/daily-repo-status.md b/.github/workflows/daily-repo-status.md deleted file mode 100644 index 4e219ef6..00000000 --- a/.github/workflows/daily-repo-status.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -description: | - This workflow creates daily repo status reports. It gathers recent repository - activity (issues, PRs, discussions, releases, code changes) and generates - engaging GitHub issues with productivity insights, community highlights, - and project recommendations. - -on: - schedule: - - cron: "47 23 * * *" - workflow_dispatch: - -permissions: - contents: read - issues: read - pull-requests: read - -network: defaults - -tools: - github: - mode: remote - toolsets: [repos, issues, pull_requests] - lockdown: false - github-app: - app-id: ${{ secrets.GH_AG_APP_ID }} - private-key: ${{ secrets.GH_AG_APP_PRIVATE_KEY }} - owner: "growth-ecosystems" - repositories: ["scope-core", "scope-core-infra"] - -safe-outputs: - mentions: false - allowed-github-references: [] - create-issue: - title-prefix: "[repo-status] " - labels: [agentic-workflows] - close-older-issues: true -source: githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f ---- - -# Daily Repo Status - -Create an upbeat daily status report for the repo as a GitHub issue. - -## What to include - -- Recent repository activity (issues, PRs, discussions, releases, code changes) from both `growth-ecosystems/scope-core` and `growth-ecosystems/scope-core-infra` -- Infrastructure changes and deployment status from the infra repo -- Progress tracking, goal reminders and highlights -- Project status and recommendations -- Actionable next steps for maintainers - -## Style - -- Be positive, encouraging, and helpful 🌟 -- Use emojis moderately for engagement -- Keep it concise - adjust length based on actual activity - -## Process - -1. Gather recent activity from the repository -2. Study the repository, its issues and its pull requests -3. Create a new GitHub issue with your findings and insights diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml deleted file mode 100644 index dd709a16..00000000 --- a/.github/workflows/publish-cli.yml +++ /dev/null @@ -1,136 +0,0 @@ -name: Publish CLI - -on: - workflow_dispatch: - inputs: - bump: - description: "Version bump type" - required: true - default: "minor" - type: choice - options: - - patch - - minor - - major - -permissions: - contents: write - -env: - SCOPE_DOC_REPO: growth-ecosystems/scope-doc - -jobs: - test: - name: Build & Test CLI - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install pnpm - run: | - corepack enable - corepack prepare pnpm@10.29.1 --activate - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Resolve current CLI version from git tag - working-directory: apps/cli - env: - GH_TOKEN: ${{ github.token }} - run: | - CURRENT=$(gh api repos/${{ github.repository }}/git/matching-refs/tags/cli/v \ - --jq '[.[].ref | sub("refs/tags/cli/v"; "")] | sort_by(split(".") | map(tonumber)) | last') - if [ -z "$CURRENT" ] || [ "$CURRENT" = "null" ]; then - echo "::error::No cli/v* tag found. Create an initial tag (e.g. cli/v0.0.0) before publishing." - exit 1 - fi - echo "Resolved current CLI version from tag: ${CURRENT}" - pnpm version "${CURRENT}" --no-git-tag-version --allow-same-version - - - name: Bump version - id: version - working-directory: apps/cli - run: | - NEW_VERSION=$(pnpm version ${{ inputs.bump }} --no-git-tag-version | tr -d 'v') - echo "version=${NEW_VERSION}" >> "$GITHUB_OUTPUT" - - - name: Build CLI bundle - working-directory: apps/cli - env: - SCOPE_DEFAULT_API_URL: ${{ vars.SCOPE_API_URL }} - run: pnpm build - - - name: Run integration tests - run: pnpm vitest run --config vitest.integration.config.ts apps/cli/src/bundle.integration.test.ts - - - name: Upload bundle - uses: actions/upload-artifact@v4 - with: - name: cli-bundle - path: apps/cli/dist/scope.mjs - - outputs: - version: ${{ steps.version.outputs.version }} - - publish: - name: Publish Release - needs: test - runs-on: ubuntu-latest - - steps: - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.FLUX_APP_ID }} - private-key: ${{ secrets.FLUX_APP_PRIVATE_KEY }} - owner: growth-ecosystems - repositories: scope-core,scope-doc - - - name: Checkout repository - uses: actions/checkout@v4 - with: - token: ${{ steps.app-token.outputs.token }} - - - name: Download bundle - uses: actions/download-artifact@v4 - with: - name: cli-bundle - path: apps/cli/dist - - - name: Create tag - env: - VERSION: ${{ needs.test.outputs.version }} - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "cli/v${VERSION}" -m "CLI release v${VERSION}" - git push origin "cli/v${VERSION}" - - - name: Create release on scope-doc - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - VERSION: ${{ needs.test.outputs.version }} - run: | - gh release create "cli/v${VERSION}" \ - --repo "$SCOPE_DOC_REPO" \ - --title "Scope CLI v${VERSION}" \ - --notes "## Installation - - \`\`\`bash - gh api repos/growth-ecosystems/scope-doc/contents/install-cli.sh -H \"Accept: application/vnd.github.raw\" | bash - \`\`\` - - Or download \`scope.mjs\` from this release and place it in your PATH. - - Requires Node.js >= 20." \ - apps/cli/dist/scope.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f397465e..92ac3e0f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,25 +119,35 @@ pnpm test:coverage # Unit tests with a coverage report pnpm test:integration # Integration tests (requires a .env file + Docker) ``` -CI's worker integration, queue recovery, and image-publishing jobs intentionally +[CI's](./.github/workflows/ci.yml) worker integration and queue recovery jobs require `github.repository == 'microsoft/scope'`: they do not execute in fork repositories. A fork PR into upstream still runs the ACP workers' tool checks and the disposable MongoDB/Redis/Azurite queue tests. Worker credentials are withheld from fork-head code, so the existing live-auth tests skip when credentials are absent. Docker Hub login is optional and restricted to trusted code; public images -can be pulled anonymously. LLM evals and Azure image publishing require trusted -PR heads; OIDC is granted only to the publishing jobs. PR test reporting and video +can be pulled anonymously. LLM evals require trusted PR heads. +The OSS CI workflow does not publish container images or request OIDC; +its ACP test images are built and loaded locally. Public CI requires no Azure/ACR +setup. PR test reporting and video uploads remain enabled, with missing video directories treated as no recordings. Changes to the CI workflow select the integration checks through a dedicated -path filter without selecting every application image. Run the focused workflow +path filter. Run the focused workflow regressions with `pnpm exec vitest run scripts/ci-workflow.test.ts` and the real queue tests with `pnpm test:integration:queue` (Docker required). The ACP matrix covers the existing Copilot and Claude workers; the removed VS Code workers have no Dockerfiles or integration suites in this repository. -Live-model credentials and the `integration` environment's Azure/OIDC/ACR -configuration remain maintainer prerequisites; passing public tests does not -validate cloud publishing or live-model access. +Live-model credentials remain necessary for live-auth tests; passing public +tool checks does not validate live-model access. + +The internal `scope-core` repository is separate. Its ACR publishers (including +Windows base images), cross-repository infrastructure status report, and +FLUX-token CLI publication to `scope-doc` are not OSS automation and have been +removed here. This does not change that repository, local Docker builds, the CLI +bundle/build tests, or public GitHub Pages. OSS automated binary-release +publishing is not implemented by this change; legacy CLI installation/updater +references remain a separate distribution migration topic. See +[CLI distribution](./docs/architecture/cli-distribution.md). ## Build, lint, and typecheck @@ -315,7 +325,6 @@ Automations depend on these exact names: | --- | --- | | Worker version checker and upgrade workflow | `type: worker-update` | | Test Improver issues, PRs, and monthly-summary searches | `type: automation`, `topic: testing` | -| Daily repository status reports | `agentic-workflows` | | Dependabot | `type: dependencies`, plus `language: javascript` or `language: rust` | Use `area: reporting` for Scope's benchmark reporting component, not daily repository activity. @@ -345,7 +354,6 @@ by hand. Use each file's recorded compiler version to avoid unrelated runtime up | Workflow | Compiler | | --- | --- | | `daily-test-improver` | `v0.57.1` | -| `daily-repo-status` | `v0.60.0` | | `worker-version-upgrade` | `v0.63.0` | The daily schedules are explicit cron expressions preserving their existing UTC execution times. diff --git a/apps/workers/coder-acp-copilot-windows/Dockerfile.windows b/apps/workers/coder-acp-copilot-windows/Dockerfile.windows index 99557f5f..98ad4b4f 100644 --- a/apps/workers/coder-acp-copilot-windows/Dockerfile.windows +++ b/apps/workers/coder-acp-copilot-windows/Dockerfile.windows @@ -2,7 +2,7 @@ ARG COPILOT_CLI_VERSION ARG DEPS_IMAGE # --- Base stage: pre-built image with Node.js, Git, pnpm, and Copilot CLI --- -# Built separately via build-windows-base.yml to avoid reinstalling tools on every CI run. +# Build Dockerfile.base and Dockerfile.deps separately, then pass DEPS_IMAGE. FROM ${DEPS_IMAGE} AS base # --- Builder stage: compile TypeScript --- diff --git a/docs/architecture/cli-distribution.md b/docs/architecture/cli-distribution.md index b46566de..9af0f4fe 100644 --- a/docs/architecture/cli-distribution.md +++ b/docs/architecture/cli-distribution.md @@ -1,15 +1,24 @@ # CLI Distribution -How the Scope CLI is bundled, distributed, and updated as a standalone tool. +How the Scope CLI is bundled, and how the legacy standalone distribution path works. + +The OSS repository retains CLI source, local bundling, bundle integration tests, +and existing installation/update functionality. Its former `publish-cli.yml` +workflow depended on a FLUX GitHub App scoped to `growth-ecosystems/scope-core` +and `growth-ecosystems/scope-doc` and has been removed. This repository does not currently +automate standalone binary releases. No replacement release architecture or +change to legacy updater URLs is introduced here. ## Overview -The CLI is bundled into a single `.mjs` file using [esbuild](https://esbuild.github.io/), distributed via GitHub Releases on the `scope-doc` repo, and installed using the `gh` CLI. This allows users to run the CLI without checking out the monorepo. +The CLI is bundled into a single `.mjs` file using [esbuild](https://esbuild.github.io/). +The legacy distribution path uses GitHub Releases on `scope-doc` and installation +through the `gh` CLI. Access to that target is separate from access to the public +OSS repository; its current support/availability is not established by this doc. ```mermaid flowchart LR - A[scope-core
apps/cli/] -->|publish-cli.yml| B[GitHub Actions] - B -->|gh release create| C[scope-doc releases
scope.mjs] + A[Legacy internal publication] --> C[Legacy scope-doc releases
scope.mjs] C -->|install-cli.sh| D[User workstation
~/.local/bin/scope] ``` @@ -36,8 +45,8 @@ the CLI's `build` script runs `tsc --noEmit` **before** esbuild: "build:tsc": "tsc --noEmit", // standalone typecheck alias ``` -Because every CI/release entry point invokes the CLI `build` script — `pnpm build` (`pnpm -r build`, -used by the CI **Build** job and `publish-cli.yml`) and `pnpm build:cli` (used by the +Because the OSS CI entry points invoke the CLI `build` script — `pnpm build` (`pnpm -r build`, +used by the CI **Build** job) and `pnpm build:cli` (used by the **CLI Bundle Integration Tests** job) — the CLI is now typechecked automatically wherever it is built, with no separate CI step. `tsc` requires the `shared` package's `dist` to exist; every one of these entry points builds `shared` first (topologically for `pnpm -r`, explicitly for @@ -75,32 +84,28 @@ In dev mode (`pnpm cli` via tsx), these defines are not applied — the CLI fall ## Versioning -The **source of truth** for the CLI version is the git tag on `scope-core` using the `cli/v*` prefix (e.g. `cli/v0.2.0`). The `apps/cli/package.json` version is `0.0.0-dev` — a placeholder that CI resolves from the latest `cli/v*` tag and then bumps via `pnpm version` during the publish workflow. It is never committed back to `main`. +The legacy release workflow used git tags on `scope-core` with the `cli/v*` +prefix (e.g. `cli/v0.2.0`) as its version source. The `apps/cli/package.json` +version remains `0.0.0-dev` for local builds. The OSS repository has no replacement +automated version-bump/release workflow. - Local builds produce `0.0.0-dev` — clearly indicating a dev build. - Dev mode (`pnpm cli`) reports `0.1.0-dev`. -- Only CI-built releases carry a real version number. +- Legacy release builds carry a real version number. - The `cli/v*` prefix allows other monorepo components to have their own tag namespaces. -## Publishing - -The publish workflow (`.github/workflows/publish-cli.yml`) is triggered manually: - -1. Select bump type: `patch` | `minor` | `major` (default: minor) -2. Workflow resolves the current version from the latest `cli/v*` tag -3. Bumps `apps/cli/package.json` via `pnpm version` -4. Builds the bundle with prod API URL (`vars.SCOPE_API_URL`) -5. Creates a git tag `cli/v` on scope-core -6. Creates a GitHub Release on `scope-doc` with `scope.mjs` +## Publishing ownership -### Required secrets/variables +The removed internal-token-dependent workflow created a tag in its source +repository and a release in `growth-ecosystems/scope-doc`, rather than +`microsoft/scope`. It required cross-repository GitHub App credentials. +Do not configure those internal credentials in OSS to restore it. -| Name | Type | Purpose | -|------|------|---------| -| `SCOPE_DOC_TOKEN` | Secret | PAT with `contents:write` on scope-doc repo | -| `SCOPE_API_URL` | Variable | Production API URL injected at build time | +OSS CI still builds and tests the CLI bundle and uploads it as an Actions +artifact. A public standalone release destination, versioning policy, installer, +and updater migration need to be defined together in a separate change. -## Installation +## Legacy installation Users install via the `gh` CLI (required since the repo is EMU-protected): @@ -189,6 +194,6 @@ unique and need no project. See |--------|-------------------|-------------------| | Runner | tsx (TypeScript direct) | Node.js (single .mjs) | | API default | `http://localhost:3100` | `https://msscope.azurewebsites.net` | -| Version | `0.1.0-dev` | Actual semver from CI bump | +| Version | `0.1.0-dev` | Embedded package version (`0.0.0-dev` locally) | | Command name | `pnpm cli` | `scope` | | Update check | Disabled | Enabled | diff --git a/scripts/ci-workflow.test.ts b/scripts/ci-workflow.test.ts index eb4d39a5..72659c30 100644 --- a/scripts/ci-workflow.test.ts +++ b/scripts/ci-workflow.test.ts @@ -23,6 +23,7 @@ interface Step { interface Job { if?: string; + needs?: string[]; permissions?: Record; env?: Record; outputs?: Record; @@ -50,7 +51,7 @@ afterEach(() => { }); describe("CI execution prerequisites", () => { - it("selects integration checks for workflow changes without marking every image changed", () => { + it("selects integration checks for workflow changes through a dedicated filter", () => { const filtersStep = jobs["detect-changes"].steps.find((candidate) => candidate.uses?.startsWith("dorny/paths-filter@")); const filters = parse(filtersStep!.with!.filters) as Record; expect(filters.ci).toContain(".github/workflows/ci.yml"); @@ -72,11 +73,15 @@ describe("CI execution prerequisites", () => { expect(existsSync(path), path).toBe(true); } expect(worker.images).toContain(`-f ${worker.dockerfile}`); + expect(worker.images).toContain("--load"); + expect(worker.images).not.toContain("--push"); + expect(spawnSync("bash", ["-n"], { input: worker.images }).status).toBe(0); expect(readdirSync(join(worker.test_pattern, "src")).some((file) => file.endsWith(".integration.test.ts"))).toBe(true); } + expect(step("integration-test", "Pre-build Docker test images").run).toContain("${{ matrix.worker.images }}"); }); - it("parses the inline shell scripts, including every image-tag case arm", () => { + it("parses the remaining inline shell scripts", () => { for (const job of Object.values(jobs)) { for (const command of job.steps.filter((candidate) => candidate.run)) { const script = command.run!.replace(/\$\{\{.*?\}\}/g, "placeholder"); @@ -87,15 +92,6 @@ describe("CI execution prerequisites", () => { } }); - it("hashes the same existing Copilot versions file that Windows loads for its build", () => { - const load = step("build-windows-image", "Load pinned versions").run!; - const versionFile = load.match(/VERSION_FILE="([^"]+)"/)![1]; - expect(existsSync(versionFile)).toBe(true); - const hash = step("build-windows-image", "Resolve deps image tag").run!; - expect(hash).toContain(`Dockerfile.deps ${versionFile} \${WORKER_DIR}/Dockerfile.base`); - expect(hash).not.toContain("${WORKER_DIR}/versions.env"); - }); - for (const recordings of [false, true]) { it(`collects videos successfully ${recordings ? "with nested recordings and spaces in paths" : "without any test-output directories"}`, () => { const directory = mkdtempSync(join(tmpdir(), "scope-ci-video-")); @@ -121,10 +117,19 @@ describe("CI execution prerequisites", () => { }); describe("CI repository and credential boundaries", () => { - it("retains all four canonical repository gates", () => { - for (const name of ["integration-test", "integration-test-queue", "build-images", "build-windows-image"]) { - expect(jobs[name].if).toContain("github.repository == 'microsoft/scope' &&"); + it.each([ + { repository: "microsoft/scope", integration: true }, + { repository: "growth-ecosystems/scope-core", integration: false }, + { repository: "cedricvidal/scope", integration: false }, + ])("selects public integration checks only in their owning repository: $repository", ({ repository, integration }) => { + for (const name of ["integration-test", "integration-test-queue"]) { + const gate = jobs[name].if!.match(/github\.repository == '([^']+)' &&/); + expect(gate, `${name} must retain a mandatory repository gate`).not.toBeNull(); + expect(gate![1] === repository, name).toBe(integration); } + }); + + it("does not run fork code through pull_request_target", () => { expect(workflow.on).not.toHaveProperty("pull_request_target"); }); @@ -143,17 +148,36 @@ describe("CI repository and credential boundaries", () => { } }); - it("reserves OIDC for trusted publishers while preserving PR reporting permissions and steps", () => { + it("has no cloud publishers or OIDC in OSS CI, while preserving reporting and CLI bundles", () => { expect(workflow.permissions).not.toHaveProperty("id-token"); expect(workflow.permissions["pull-requests"]).toBe("write"); expect(workflow.permissions.issues).toBe("write"); - for (const name of ["build-images", "build-windows-image"]) { - expect(jobs[name].if).toContain(trusted); - expect(jobs[name].permissions).toEqual({ contents: "read", "id-token": "write" }); + expect(jobs).not.toHaveProperty("build-images"); + expect(jobs).not.toHaveProperty("build-windows-image"); + expect(workflow.on.workflow_dispatch).toBeNull(); + for (const job of Object.values(jobs)) { + expect(job.permissions?.["id-token"]).toBeUndefined(); + expect(JSON.stringify(job)).not.toMatch(/azure\/login|az acr|ACR_NAME|scope-core/); + for (const dependency of job.needs ?? []) expect(jobs).toHaveProperty(dependency); } expect(jobs["llm-evals"].if).toContain(trusted); for (const name of ["test", "gateway"]) { expect(step(name, "Post test results to Pull Request").run).toContain("github-actions-ctrf pull-request"); } + expect(step("cli-bundle-test", "Build CLI bundle").run).toBe("pnpm build:cli"); + expect(step("cli-bundle-test", "Upload CLI bundle").with?.path).toBe("apps/cli/dist/scope.mjs"); + }); + + it("removes internal-only automation without removing public Pages or repository maintenance", () => { + for (const file of ["build-windows-base.yml", "daily-repo-status.md", "daily-repo-status.lock.yml", "publish-cli.yml"]) { + expect(existsSync(join(".github/workflows", file)), file).toBe(false); + } + for (const file of ["static.yml", "gitleaks.yml", "check-worker-versions.yml", "daily-test-improver.md", "daily-test-improver.lock.yml", "worker-version-upgrade.md", "worker-version-upgrade.lock.yml"]) { + expect(existsSync(join(".github/workflows", file)), file).toBe(true); + } + for (const file of readdirSync(".github/workflows").filter((file) => /\.ya?ml$/.test(file))) { + const source = readFileSync(join(".github/workflows", file), "utf8"); + expect(source, file).not.toMatch(/github\.repository == 'growth-ecosystems\/scope-core'|vars\.ACR_NAME/); + } }); }); From d326467941db5c5b4368e702444340949cb8a7f3 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 29 Sep 2026 00:11:40 -0700 Subject: [PATCH 6/8] ci: make Windows builds and CLI releases self-contained Validate the Windows base, pinned dependencies and worker on a hosted Windows runner using local images only. Propagate native Dockerfile failures and require Windows validation in CI Summary. Restore manual main-only CLI releases to microsoft/scope with the repository token, serialized version selection, tested artifacts and a public installer/updater destination. Preserve Linux integration jobs and public automation without internal infrastructure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 45 ++++++- .github/workflows/publish-cli.yml | 114 ++++++++++++++++++ CONTRIBUTING.md | 29 +++-- apps/cli/README.md | 14 ++- apps/cli/package.json | 2 +- apps/cli/src/commands/update.test.ts | 12 +- apps/cli/src/commands/update.ts | 10 +- apps/cli/src/utils/update-check.test.ts | 47 ++++++++ apps/cli/src/utils/update-check.ts | 8 +- .../coder-acp-copilot-windows/Dockerfile.base | 5 +- .../coder-acp-copilot-windows/Dockerfile.deps | 2 +- .../Dockerfile.windows | 11 +- docs/architecture/cli-distribution.md | 86 +++++++------ install-cli.sh | 39 ++++++ scripts/build-windows-worker.ps1 | 25 ++++ scripts/build-windows-worker.test.ts | 77 ++++++++++++ scripts/ci-workflow.test.ts | 99 ++++++++++++++- scripts/install-cli.test.ts | 71 +++++++++++ 18 files changed, 618 insertions(+), 78 deletions(-) create mode 100644 .github/workflows/publish-cli.yml create mode 100644 apps/cli/src/utils/update-check.test.ts create mode 100755 install-cli.sh create mode 100644 scripts/build-windows-worker.ps1 create mode 100644 scripts/build-windows-worker.test.ts create mode 100644 scripts/install-cli.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 257b0486..19887524 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,9 @@ on: - "vitest.config.ts" - "vitest.integration.config.ts" - ".github/workflows/ci.yml" + - ".github/workflows/publish-cli.yml" + - ".dockerignore" + - "install-cli.sh" pull_request: branches: [main] paths: @@ -29,6 +32,9 @@ on: - "vitest.config.ts" - "vitest.integration.config.ts" - ".github/workflows/ci.yml" + - ".github/workflows/publish-cli.yml" + - ".dockerignore" + - "install-cli.sh" workflow_dispatch: permissions: @@ -731,8 +737,30 @@ jobs: - name: Verify NOTICE is up to date run: pnpm notice:check + windows-build: + name: Windows Worker Build + needs: [detect-changes] + if: > + github.repository == 'microsoft/scope' && + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.ci == 'true' || + needs.detect-changes.outputs.coder-acp-copilot-windows == 'true' || + needs.detect-changes.outputs.coder-acp-copilot == 'true' || + needs.detect-changes.outputs.shared == 'true') + runs-on: windows-2022 + timeout-minutes: 60 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Build and smoke-test local Windows images + shell: pwsh + run: ./scripts/build-windows-worker.ps1 + # --------------------------------------------------------------------------- - # Detect which images changed (runs in parallel with CI jobs) + # Detect changed components (runs in parallel with CI jobs) # --------------------------------------------------------------------------- detect-changes: runs-on: ubuntu-latest @@ -805,9 +833,20 @@ jobs: coder-acp-copilot-windows: - 'apps/workers/coder-acp-copilot-windows/**' - 'apps/workers/coder-acp-copilot/versions.env' + - 'packages/telemetry/**' + - 'scripts/build-windows-worker.ps1' + - 'pnpm-workspace.yaml' + - '.dockerignore' typescript: - '.github/workflows/ci.yml' + - '.github/workflows/publish-cli.yml' - 'scripts/ci-workflow.test.ts' + - 'scripts/install-cli.test.ts' + - 'scripts/build-windows-worker.test.ts' + - 'scripts/build-windows-worker.ps1' + - 'install-cli.sh' + - '.dockerignore' + - 'pnpm-workspace.yaml' - 'apps/**' - '!apps/gateway/**' - 'packages/**' @@ -829,7 +868,7 @@ jobs: ci-summary: name: CI Summary runs-on: ubuntu-latest - needs: [test, integration-test, integration-test-queue, lint, build, cli-bundle-test, gateway, llm-evals, notice-check, license-headers] + needs: [test, integration-test, integration-test-queue, windows-build, lint, build, cli-bundle-test, gateway, llm-evals, notice-check, license-headers] if: always() steps: @@ -838,6 +877,7 @@ jobs: if [ "${{ needs.test.result }}" = "success" ] && \ [ "${{ needs.integration-test.result }}" = "success" -o "${{ needs.integration-test.result }}" = "skipped" ] && \ [ "${{ needs.integration-test-queue.result }}" = "success" -o "${{ needs.integration-test-queue.result }}" = "skipped" ] && \ + [ "${{ needs.windows-build.result }}" = "success" -o "${{ needs.windows-build.result }}" = "skipped" ] && \ [ "${{ needs.lint.result }}" = "success" ] && \ [ "${{ needs.build.result }}" = "success" ] && \ [ "${{ needs.cli-bundle-test.result }}" = "success" -o "${{ needs.cli-bundle-test.result }}" = "skipped" ] && \ @@ -852,6 +892,7 @@ jobs: echo " Unit Tests: ${{ needs.test.result }}" echo " Integration Tests: ${{ needs.integration-test.result }}" echo " Integration Tests (queue recovery): ${{ needs.integration-test-queue.result }}" + echo " Windows Worker Build: ${{ needs.windows-build.result }}" echo " Lint: ${{ needs.lint.result }}" echo " Build: ${{ needs.build.result }}" echo " CLI Bundle Tests: ${{ needs.cli-bundle-test.result }}" diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml new file mode 100644 index 00000000..290fe35b --- /dev/null +++ b/.github/workflows/publish-cli.yml @@ -0,0 +1,114 @@ +name: Publish CLI + +on: + workflow_dispatch: + inputs: + bump: + description: Version bump type + required: true + default: minor + type: choice + options: [patch, minor, major] + +permissions: + contents: read + +concurrency: + group: publish-cli + cancel-in-progress: false + +jobs: + test: + name: Build and test release bundle + if: github.repository == 'microsoft/scope' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install pnpm + run: | + corepack enable + corepack prepare pnpm@10.29.1 --activate + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Resolve next CLI version + id: version + working-directory: apps/cli + env: + BUMP: ${{ inputs.bump }} + run: | + node --input-type=module <<'NODE' + import { execFileSync } from 'node:child_process'; + import { readFileSync, appendFileSync } from 'node:fs'; + import semver from 'semver'; + if (!['patch', 'minor', 'major'].includes(process.env.BUMP)) throw new Error('Invalid version bump'); + const tags = execFileSync('git', ['tag', '--list', 'cli/v*'], { encoding: 'utf8' }).trim(); + const versions = tags ? tags.split('\n').map(tag => { + const version = tag.slice('cli/v'.length); + if (!semver.valid(version)) throw new Error(`Invalid CLI tag: ${tag}`); + return version; + }) : []; + let current = versions.sort(semver.rcompare)[0]; + if (!current) { + const pkg = JSON.parse(readFileSync('package.json', 'utf8')); + const baseline = semver.parse(pkg.version); + if (!baseline) throw new Error('Invalid package version for initial release'); + current = `${baseline.major}.${baseline.minor}.${baseline.patch}`; + console.log(`No cli/v* tags: bootstrapping from package version ${pkg.version} (release baseline ${current})`); + } + const version = semver.inc(current, process.env.BUMP); + if (!version) throw new Error('Unable to increment CLI version'); + appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`); + NODE + - name: Set release version + env: + VERSION: ${{ steps.version.outputs.version }} + run: pnpm --dir apps/cli version "$VERSION" --no-git-tag-version + - name: Build CLI bundle + run: pnpm build:cli + - name: Test release bundle + env: + VERSION: ${{ steps.version.outputs.version }} + SCOPE_NO_UPDATE_CHECK: "1" + run: | + test "$(node apps/cli/dist/scope.mjs --version)" = "$VERSION" + pnpm exec vitest run --config vitest.integration.config.ts apps/cli/src/bundle.integration.test.ts + - name: Upload tested bundle + uses: actions/upload-artifact@v4 + with: + name: cli-release-bundle + path: apps/cli/dist/scope.mjs + if-no-files-found: error + + publish: + name: Publish public CLI release + needs: test + if: github.repository == 'microsoft/scope' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: cli-release-bundle + path: bundle + - name: Create release in this repository + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.test.outputs.version }} + COMMIT: ${{ github.sha }} + run: | + gh release create "cli/v${VERSION}" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$COMMIT" \ + --title "Scope CLI v${VERSION}" \ + --notes "Standalone Scope CLI. Install: https://github.com/microsoft/scope/blob/main/apps/cli/README.md#installation" \ + bundle/scope.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 92ac3e0f..2278c512 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,7 +119,7 @@ pnpm test:coverage # Unit tests with a coverage report pnpm test:integration # Integration tests (requires a .env file + Docker) ``` -[CI's](./.github/workflows/ci.yml) worker integration and queue recovery jobs +[CI's](./.github/workflows/ci.yml) worker integration, queue recovery and Windows build jobs require `github.repository == 'microsoft/scope'`: they do not execute in fork repositories. A fork PR into upstream still runs the ACP workers' tool checks and the disposable MongoDB/Redis/Azurite queue tests. Worker credentials are withheld @@ -140,14 +140,25 @@ workers have no Dockerfiles or integration suites in this repository. Live-model credentials remain necessary for live-auth tests; passing public tool checks does not validate live-model access. -The internal `scope-core` repository is separate. Its ACR publishers (including -Windows base images), cross-repository infrastructure status report, and -FLUX-token CLI publication to `scope-doc` are not OSS automation and have been -removed here. This does not change that repository, local Docker builds, the CLI -bundle/build tests, or public GitHub Pages. OSS automated binary-release -publishing is not implemented by this change; legacy CLI installation/updater -references remain a separate distribution migration topic. See -[CLI distribution](./docs/architecture/cli-distribution.md). +Windows validation uses GitHub's `windows-2022` runner and its Windows Docker +engine. `scripts/build-windows-worker.ps1` builds `Dockerfile.base` from public +`servercore:ltsc2022`, builds pinned Copilot dependencies against that local image, +then builds and smoke-tests the worker against the local dependencies image. +No internal registry, prebuilt private image, secrets or image publication is +required. Changes to the Windows worker, Linux Copilot dependency, shared packages, +telemetry, workspace/build context, or CI select this check; failures block +CI Summary. Run the same script locally on Windows Server 2022 with Docker. +The PowerShell orchestration tests require `pwsh` (included on GitHub's Ubuntu +runners); they mock Docker and do not replace the hosted Windows image build. + +The OSS repository does not depend on internal `scope-core` automation. The +internal infrastructure status report and cloud image publishers are removed; +Windows build validation is local, while CLI release publication is retained in +this repository using its own `GITHUB_TOKEN`. Public Pages is unchanged. The +manual CLI release workflow runs only on upstream `main`, builds and tests the +exact bundle before publishing it to `microsoft/scope`, and needs no FLUX token. +See [CLI distribution](./docs/architecture/cli-distribution.md) for versioning, +installation and update behavior. ## Build, lint, and typecheck diff --git a/apps/cli/README.md b/apps/cli/README.md index 01fd990d..00c057bf 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -5,14 +5,16 @@ Command-line interface for the Scope AI coding agent benchmarking platform. ## Installation ```bash -gh api repos/growth-ecosystems/scope-doc/contents/install-cli.sh -H "Accept: application/vnd.github.raw" | bash +curl --fail --location https://raw.githubusercontent.com/microsoft/scope/main/install-cli.sh | bash ``` **Prerequisites:** - Node.js >= 20 -- `gh` CLI installed and authenticated (`gh auth login`) +- `curl` (no GitHub authentication required for installation) -The installer downloads the latest release and places `scope` in `~/.local/bin/`. Add it to your PATH if needed: +The installer downloads the latest published `cli/v*` release from `microsoft/scope` +and places `scope` in `~/.local/bin/` (override with `SCOPE_INSTALL_DIR`). It fails +clearly if no CLI release has been published yet. Add it to your PATH if needed: ```bash export PATH="$HOME/.local/bin:$PATH" @@ -90,10 +92,12 @@ Update to the latest version: scope update ``` -Or re-run the install script: +This command requires `gh` installed and authenticated (`gh auth login`) and +downloads from the same public `microsoft/scope` repository. +Alternatively, re-run the install script without `gh`: ```bash -gh api repos/growth-ecosystems/scope-doc/contents/install-cli.sh -H "Accept: application/vnd.github.raw" | bash +curl --fail --location https://raw.githubusercontent.com/microsoft/scope/main/install-cli.sh | bash ``` The CLI will also notify you when a newer version is available. Suppress this with: diff --git a/apps/cli/package.json b/apps/cli/package.json index b7f83861..3a73a038 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -11,7 +11,7 @@ ], "repository": { "type": "git", - "url": "https://github.com/growth-ecosystems/scope-core.git", + "url": "https://github.com/microsoft/scope.git", "directory": "apps/cli" }, "scripts": { diff --git a/apps/cli/src/commands/update.test.ts b/apps/cli/src/commands/update.test.ts index 004dd6c6..25aec639 100644 --- a/apps/cli/src/commands/update.test.ts +++ b/apps/cli/src/commands/update.test.ts @@ -18,6 +18,7 @@ vi.mock("node:fs", () => ({ // Mock the update-check module vi.mock("../utils/update-check.js", () => ({ fetchLatestVersion: vi.fn(), + RELEASES_REPO: "microsoft/scope", })); import { Command } from "commander"; @@ -98,7 +99,7 @@ describe("update command", () => { expect.stringContaining("New version available: 0.3.0"), ); expect(mockedExecSync).toHaveBeenCalledWith( - expect.stringContaining("gh release download"), + expect.stringContaining('gh release download "cli/v0.3.0" --repo microsoft/scope'), expect.anything(), ); }); @@ -120,7 +121,11 @@ describe("update command", () => { await program.parseAsync(["node", "scope", "update"]); expect(mockedExecSync).toHaveBeenCalledWith( - expect.stringContaining("gh release download"), + expect.stringContaining("gh release list --repo microsoft/scope"), + expect.anything(), + ); + expect(mockedExecSync).toHaveBeenCalledWith( + expect.stringContaining('gh release download "cli/v0.3.0" --repo microsoft/scope'), expect.anything(), ); }); @@ -169,6 +174,9 @@ describe("update command", () => { expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("Update failed"), ); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("https://raw.githubusercontent.com/microsoft/scope/main/install-cli.sh"), + ); }); it("verifies new version after install", async () => { diff --git a/apps/cli/src/commands/update.ts b/apps/cli/src/commands/update.ts index 2448a952..0cbed971 100644 --- a/apps/cli/src/commands/update.ts +++ b/apps/cli/src/commands/update.ts @@ -7,9 +7,7 @@ import { chmodSync, renameSync, unlinkSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import semver from "semver"; import { getCliName } from "../utils/shared.js"; -import { fetchLatestVersion } from "../utils/update-check.js"; - -const REPO = "growth-ecosystems/scope-doc"; +import { fetchLatestVersion, RELEASES_REPO } from "../utils/update-check.js"; function getCliVersion(): string { return process.env.SCOPE_CLI_VERSION ?? "0.1.0-dev"; @@ -51,7 +49,7 @@ export function registerUpdateCommand(program: Command): void { // Resolve tag via gh release list try { targetTag = execSync( - `gh release list --repo ${REPO} --json tagName -q '[.[].tagName | select(startswith("cli/v"))][0]'`, + `gh release list --repo ${RELEASES_REPO} --json tagName -q '[.[].tagName | select(startswith("cli/v"))][0]'`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }, ).trim(); } catch { /* ignore */ } @@ -71,7 +69,7 @@ export function registerUpdateCommand(program: Command): void { try { // Download scope.mjs to a temp file, then atomically replace execSync( - `gh release download "${targetTag}" --repo ${REPO} --pattern scope.mjs -O "${tmpFile}" --clobber`, + `gh release download "${targetTag}" --repo ${RELEASES_REPO} --pattern scope.mjs -O "${tmpFile}" --clobber`, { stdio: "inherit" }, ); chmodSync(tmpFile, 0o755); @@ -85,7 +83,7 @@ export function registerUpdateCommand(program: Command): void { try { unlinkSync(tmpFile); } catch { /* ignore */ } console.error( "\nUpdate failed. You can reinstall manually:\n" + - " gh api repos/" + REPO + "/contents/install-cli.sh -H \"Accept: application/vnd.github.raw\" | bash", + " curl --fail --location https://raw.githubusercontent.com/" + RELEASES_REPO + "/main/install-cli.sh | bash", ); process.exit(1); } diff --git a/apps/cli/src/utils/update-check.test.ts b/apps/cli/src/utils/update-check.test.ts new file mode 100644 index 00000000..2c891c14 --- /dev/null +++ b/apps/cli/src/utils/update-check.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { execSync } from "node:child_process"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:child_process", () => ({ execSync: vi.fn() })); + +describe("public CLI release lookup", () => { + beforeEach(() => { + vi.resetModules(); + vi.stubEnv("SCOPE_RELEASES_URL", ""); + vi.stubEnv("GH_TOKEN", ""); + vi.stubEnv("GITHUB_TOKEN", ""); + vi.stubEnv("SCOPE_TOKEN", "scope-service-token"); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("uses the public repository anonymously when gh is unavailable, without leaking the Scope bearer", async () => { + vi.mocked(execSync).mockImplementation(() => { throw new Error("gh unavailable"); }); + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify([{ tag_name: "cli/v1.2.3" }]))); + vi.stubGlobal("fetch", fetchMock); + const { RELEASES_REPO, RELEASES_URL, fetchLatestVersion } = await import("./update-check.js"); + expect(RELEASES_REPO).toBe("microsoft/scope"); + expect(RELEASES_URL).toBe("https://api.github.com/repos/microsoft/scope/releases"); + expect(await fetchLatestVersion()).toBe("1.2.3"); + expect(fetchMock).toHaveBeenCalledWith(RELEASES_URL, { + signal: expect.any(AbortSignal), + headers: { Accept: "application/vnd.github.v3+json" }, + }); + }); + + it("targets the same public repository through gh when available", async () => { + vi.mocked(execSync).mockReturnValue("cli/v2.0.0\n"); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const { fetchLatestVersion } = await import("./update-check.js"); + expect(await fetchLatestVersion()).toBe("2.0.0"); + expect(execSync).toHaveBeenCalledWith(expect.stringContaining("gh release list --repo microsoft/scope"), expect.anything()); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/cli/src/utils/update-check.ts b/apps/cli/src/utils/update-check.ts index d0c9d7e9..b7f90e59 100644 --- a/apps/cli/src/utils/update-check.ts +++ b/apps/cli/src/utils/update-check.ts @@ -70,7 +70,7 @@ export function checkForUpdates(currentVersion: string): () => Promise { }; } -export const RELEASES_REPO = "growth-ecosystems/scope-doc"; +export const RELEASES_REPO = "microsoft/scope"; export const RELEASES_URL = process.env.SCOPE_RELEASES_URL || @@ -79,14 +79,14 @@ export const RELEASES_URL = /** * Fetch the latest released CLI version. * Only considers releases with a `cli/v*` tag prefix. - * Uses `gh release list` (handles EMU auth), falls back to REST API. + * Uses `gh release list`, falling back to the public REST API. * Returns the version string (without prefix) or undefined on failure. * Timeout defaults to 5000ms but can be overridden (background check uses 2000ms). */ export async function fetchLatestVersion(timeoutMs = 5000): Promise { // Skip gh CLI when a custom SCOPE_RELEASES_URL is set (e.g. in tests) if (!process.env.SCOPE_RELEASES_URL) { - // Prefer gh CLI — it handles EMU/private repo auth natively + // Prefer gh CLI when available; its configured token also avoids anonymous rate limits. try { const tag = execSync( `gh release list --repo ${RELEASES_REPO} --json tagName -q '[.[].tagName | select(startswith("cli/v"))][0]'`, @@ -100,7 +100,7 @@ export async function fetchLatestVersion(timeoutMs = 5000): Promise controller.abort(), timeoutMs); diff --git a/apps/workers/coder-acp-copilot-windows/Dockerfile.base b/apps/workers/coder-acp-copilot-windows/Dockerfile.base index 441af47d..ab16cd23 100644 --- a/apps/workers/coder-acp-copilot-windows/Dockerfile.base +++ b/apps/workers/coder-acp-copilot-windows/Dockerfile.base @@ -14,11 +14,12 @@ RUN Set-ExecutionPolicy Bypass -Scope Process -Force; \ ARG NODE_VERSION=22.22.3 RUN Invoke-WebRequest -Uri "https://nodejs.org/dist/v${env:NODE_VERSION}/node-v${env:NODE_VERSION}-x64.msi" \ -OutFile C:\node.msi; \ - Start-Process msiexec.exe -ArgumentList '/i', 'C:\node.msi', '/quiet', '/norestart' -Wait; \ + $installer = Start-Process msiexec.exe -ArgumentList '/i', 'C:\node.msi', '/quiet', '/norestart' -Wait -PassThru; \ + if ($installer.ExitCode -notin @(0, 3010)) { throw "Node installer failed: $($installer.ExitCode)" }; \ Remove-Item C:\node.msi # Install git via Chocolatey -RUN choco install -y git +RUN choco install -y git; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # Install pnpm as a standalone executable RUN New-Item -ItemType Directory -Force -Path C:\tools | Out-Null; \ diff --git a/apps/workers/coder-acp-copilot-windows/Dockerfile.deps b/apps/workers/coder-acp-copilot-windows/Dockerfile.deps index 9831e1ed..f2e1db1a 100644 --- a/apps/workers/coder-acp-copilot-windows/Dockerfile.deps +++ b/apps/workers/coder-acp-copilot-windows/Dockerfile.deps @@ -8,4 +8,4 @@ SHELL ["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "-Comma # Install GitHub Copilot CLI globally to C:\tools so it lands on PATH ARG COPILOT_CLI_VERSION -RUN npm install -g --prefix C:\tools "@github/copilot@${env:COPILOT_CLI_VERSION}" +RUN npm install -g --prefix C:\tools "@github/copilot@${env:COPILOT_CLI_VERSION}"; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/apps/workers/coder-acp-copilot-windows/Dockerfile.windows b/apps/workers/coder-acp-copilot-windows/Dockerfile.windows index 98ad4b4f..582bcac0 100644 --- a/apps/workers/coder-acp-copilot-windows/Dockerfile.windows +++ b/apps/workers/coder-acp-copilot-windows/Dockerfile.windows @@ -2,7 +2,7 @@ ARG COPILOT_CLI_VERSION ARG DEPS_IMAGE # --- Base stage: pre-built image with Node.js, Git, pnpm, and Copilot CLI --- -# Build Dockerfile.base and Dockerfile.deps separately, then pass DEPS_IMAGE. +# scripts/build-windows-worker.ps1 builds base, deps and worker images locally. FROM ${DEPS_IMAGE} AS base # --- Builder stage: compile TypeScript --- @@ -18,7 +18,7 @@ COPY packages/telemetry/package.json ./packages/telemetry/ COPY apps/workers/coder-acp-copilot/package.json ./apps/workers/coder-acp-copilot/ COPY apps/workers/coder-acp-copilot-windows/package.json ./apps/workers/coder-acp-copilot-windows/ -RUN C:\tools\pnpm.exe install --frozen-lockfile +RUN C:\tools\pnpm.exe install --frozen-lockfile; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } COPY packages/shared/tsconfig.json ./packages/shared/ COPY packages/shared/src ./packages/shared/src/ @@ -29,7 +29,10 @@ COPY apps/workers/coder-acp-copilot/src ./apps/workers/coder-acp-copilot/src/ COPY apps/workers/coder-acp-copilot-windows/tsconfig.json ./apps/workers/coder-acp-copilot-windows/ COPY apps/workers/coder-acp-copilot-windows/src ./apps/workers/coder-acp-copilot-windows/src/ -RUN C:\tools\pnpm.exe --filter shared build; C:\tools\pnpm.exe --filter telemetry build; C:\tools\pnpm.exe --filter coder-acp-copilot build; C:\tools\pnpm.exe --filter coder-acp-copilot-windows build +RUN C:\tools\pnpm.exe --filter shared build; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; \ + C:\tools\pnpm.exe --filter telemetry build; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; \ + C:\tools\pnpm.exe --filter coder-acp-copilot build; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; \ + C:\tools\pnpm.exe --filter coder-acp-copilot-windows build; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # Pre-convert agent.yaml to JSON so the registration job doesn't need the yaml package at runtime COPY apps/workers/coder-acp-copilot-windows/agent.yaml ./apps/workers/coder-acp-copilot-windows/agent.yaml @@ -49,7 +52,7 @@ COPY packages/telemetry/package.json ./packages/telemetry/ COPY apps/workers/coder-acp-copilot/package.json ./apps/workers/coder-acp-copilot/ COPY apps/workers/coder-acp-copilot-windows/package.json ./apps/workers/coder-acp-copilot-windows/ -RUN C:\tools\pnpm.exe install --frozen-lockfile --prod +RUN C:\tools\pnpm.exe install --frozen-lockfile --prod; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } COPY --from=builder C:/app/packages/shared/dist ./packages/shared/dist/ COPY --from=builder C:/app/packages/telemetry/dist ./packages/telemetry/dist/ diff --git a/docs/architecture/cli-distribution.md b/docs/architecture/cli-distribution.md index 9af0f4fe..4cd84a7a 100644 --- a/docs/architecture/cli-distribution.md +++ b/docs/architecture/cli-distribution.md @@ -1,24 +1,19 @@ # CLI Distribution -How the Scope CLI is bundled, and how the legacy standalone distribution path works. - -The OSS repository retains CLI source, local bundling, bundle integration tests, -and existing installation/update functionality. Its former `publish-cli.yml` -workflow depended on a FLUX GitHub App scoped to `growth-ecosystems/scope-core` -and `growth-ecosystems/scope-doc` and has been removed. This repository does not currently -automate standalone binary releases. No replacement release architecture or -change to legacy updater URLs is introduced here. +How the Scope CLI is bundled, released, installed and updated from the public +`microsoft/scope` repository without internal repository dependencies. ## Overview The CLI is bundled into a single `.mjs` file using [esbuild](https://esbuild.github.io/). -The legacy distribution path uses GitHub Releases on `scope-doc` and installation -through the `gh` CLI. Access to that target is separate from access to the public -OSS repository; its current support/availability is not established by this doc. +The manual release workflow builds and tests the bundle, then publishes it to +GitHub Releases in the same repository. The installer and updater use that public +release destination. ```mermaid flowchart LR - A[Legacy internal publication] --> C[Legacy scope-doc releases
scope.mjs] + A[microsoft/scope
publish-cli.yml] -->|Build and test| B[Validated bundle] + B -->|GITHUB_TOKEN| C[microsoft/scope releases
scope.mjs] C -->|install-cli.sh| D[User workstation
~/.local/bin/scope] ``` @@ -47,7 +42,7 @@ the CLI's `build` script runs `tsc --noEmit` **before** esbuild: Because the OSS CI entry points invoke the CLI `build` script — `pnpm build` (`pnpm -r build`, used by the CI **Build** job) and `pnpm build:cli` (used by the -**CLI Bundle Integration Tests** job) — the CLI is now typechecked automatically wherever it is +**CLI Bundle Integration Tests** and release jobs) — the CLI is now typechecked automatically wherever it is built, with no separate CI step. `tsc` requires the `shared` package's `dist` to exist; every one of these entry points builds `shared` first (topologically for `pnpm -r`, explicitly for `build:cli`), which esbuild already required, so there is no new ordering constraint. @@ -84,51 +79,70 @@ In dev mode (`pnpm cli` via tsx), these defines are not applied — the CLI fall ## Versioning -The legacy release workflow used git tags on `scope-core` with the `cli/v*` -prefix (e.g. `cli/v0.2.0`) as its version source. The `apps/cli/package.json` -version remains `0.0.0-dev` for local builds. The OSS repository has no replacement -automated version-bump/release workflow. +The release workflow sorts valid `cli/v*` tags in `microsoft/scope` semantically +and bumps the highest version by the selected `patch`, `minor` or `major` increment. +Only when there are no matching tags does it bootstrap from the validated +`apps/cli/package.json` version's major/minor/patch components, logging that +decision and removing the development prerelease suffix before bumping. With the current +`0.0.0-dev` baseline, the default minor bump produces `0.1.0`. Invalid tags, +invalid package versions, and tag-read/fetch failures fail the workflow rather +than masquerading as an empty release history. + +The version is written only in the release workspace before building; it is not +committed back to `main`. The release tag targets the exact checked-out source SHA. +Workflow-level concurrency serializes version selection through publication, +without cancelling an in-progress release. - Local builds produce `0.0.0-dev` — clearly indicating a dev build. - Dev mode (`pnpm cli`) reports `0.1.0-dev`. -- Legacy release builds carry a real version number. +- Release builds carry the selected version number. - The `cli/v*` prefix allows other monorepo components to have their own tag namespaces. -## Publishing ownership +## Publishing -The removed internal-token-dependent workflow created a tag in its source -repository and a release in `growth-ecosystems/scope-doc`, rather than -`microsoft/scope`. It required cross-repository GitHub App credentials. -Do not configure those internal credentials in OSS to restore it. +Maintainers manually dispatch [Publish CLI](../../.github/workflows/publish-cli.yml) +on upstream `main` and select a bump type (default: minor). The read-only build +job checks out full tag history, installs locked dependencies, sets the version, +runs the typechecked bundle build and bundle integration tests, and checks the +bundle's reported version. The separate publish job downloads that exact artifact +and creates `cli/v` with `scope.mjs` attached. -OSS CI still builds and tests the CLI bundle and uploads it as an Actions -artifact. A public standalone release destination, versioning policy, installer, -and updater migration need to be defined together in a separate change. +Only the publish job gets `contents: write`, using this repository's +`GITHUB_TOKEN`. Fork repositories and non-main refs cannot publish. No FLUX app, +internal release repository, cloud environment, OIDC or custom secret is needed. +Repository rules must permit the workflow token to create release tags; rules +are not changed by this workflow. No release exists until a maintainer explicitly +runs it successfully. -## Legacy installation +## Installation -Users install via the `gh` CLI (required since the repo is EMU-protected): +Use the public installer with Node.js >= 20 and `curl`: ```bash -gh api repos/growth-ecosystems/scope-doc/contents/install-cli.sh -H "Accept: application/vnd.github.raw" | bash +curl --fail --location https://raw.githubusercontent.com/microsoft/scope/main/install-cli.sh | bash ``` -The installer (`install-cli.sh` in scope-doc): -1. Downloads `scope.mjs` from the latest `cli/v*` release -2. Places it at `~/.local/bin/scope` -3. Makes it executable +The installer selects a published, non-prerelease `cli/v*` release and downloads +`scope.mjs` with bounded retries. It verifies the reported version before +atomically replacing `~/.local/bin/scope` (`SCOPE_INSTALL_DIR` overrides the +directory). Missing releases, API/download errors and version mismatches fail +explicitly without replacing an existing installation. Installation does not +require GitHub authentication; public API rate limits still apply. -Prerequisites: Node.js >= 20, `gh` CLI authenticated. +`scope update` retains its `gh release download` implementation, so updating +in-place requires `gh` configured with GitHub authentication. Alternatively, +rerun the public installer without `gh`. ## Update check After each command, the CLI performs a non-blocking check for newer versions: -- Queries the GitHub Releases API on `scope-doc` (3s timeout) +- Checks `cli/v*` releases in `microsoft/scope` (2s per background lookup attempt) - Compares the current embedded version against the latest release tag - If newer, prints a one-line notice with the upgrade command - Suppressed by `SCOPE_NO_UPDATE_CHECK=1` -- Requires `GH_TOKEN` or `GITHUB_TOKEN` for private repo access (silently skips without it) +- Uses `gh` when available, falling back to the public REST API; `GH_TOKEN` or + `GITHUB_TOKEN` is optional for that fallback This is the **one** place in the CLI that calls `fetch` directly rather than the centralized `apiFetch()` wrapper (`apps/cli/src/utils/api-client.ts`): it targets the diff --git a/install-cli.sh b/install-cli.sh new file mode 100755 index 00000000..40e209a1 --- /dev/null +++ b/install-cli.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +set -euo pipefail + +command -v node >/dev/null || { echo "Node.js >= 20 is required." >&2; exit 1; } +command -v curl >/dev/null || { echo "curl is required." >&2; exit 1; } +node -e 'if (Number(process.versions.node.split(".")[0]) < 20) { console.error("Node.js >= 20 is required."); process.exit(1); }' + +tag="$(curl --fail --silent --show-error --location --retry 3 \ + https://api.github.com/repos/microsoft/scope/releases | node -e ' + let input = ""; + process.stdin.on("data", chunk => input += chunk); + process.stdin.on("end", () => { + const releases = JSON.parse(input); + if (!Array.isArray(releases)) throw new Error("Invalid GitHub release response"); + const release = releases.find(item => !item.draft && !item.prerelease && /^cli\/v\d+\.\d+\.\d+$/.test(item.tag_name)); + if (!release) { console.error("No published Scope CLI release is available in microsoft/scope."); process.exit(1); } + console.log(release.tag_name); + }); + ')" + +install_dir="${SCOPE_INSTALL_DIR:-$HOME/.local/bin}" +mkdir -p "$install_dir" +temp_dir="$(mktemp -d "$install_dir/.scope-install.XXXXXX")" +trap 'rm -f "$temp_dir/scope.mjs"; rmdir "$temp_dir"' EXIT +curl --fail --silent --show-error --location --retry 3 \ + "https://github.com/microsoft/scope/releases/download/cli%2Fv${tag#cli/v}/scope.mjs" \ + --output "$temp_dir/scope.mjs" +version="$(SCOPE_NO_UPDATE_CHECK=1 node "$temp_dir/scope.mjs" --version)" +if [ "$version" != "${tag#cli/v}" ]; then + echo "Downloaded bundle version does not match $tag." >&2 + exit 1 +fi +chmod 755 "$temp_dir/scope.mjs" +mv "$temp_dir/scope.mjs" "$install_dir/scope" +echo "Installed scope $version to $install_dir/scope" +echo "Ensure $install_dir is on your PATH." diff --git a/scripts/build-windows-worker.ps1 b/scripts/build-windows-worker.ps1 new file mode 100644 index 00000000..b8fd9de8 --- /dev/null +++ b/scripts/build-windows-worker.ps1 @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$worker = Join-Path $root 'apps/workers/coder-acp-copilot-windows' +$versions = Get-Content (Join-Path $root 'apps/workers/coder-acp-copilot/versions.env') +$version = @($versions | Where-Object { $_ -match '^COPILOT_CLI_VERSION=' }) +if ($version.Count -ne 1) { throw 'Expected one pinned COPILOT_CLI_VERSION' } +$version = $version[0].Split('=', 2)[1].Trim() +if ($version -notmatch '^\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$') { throw 'Invalid COPILOT_CLI_VERSION' } + +function Invoke-Docker { + param([string[]] $Arguments) + & docker @Arguments + if ($LASTEXITCODE -ne 0) { throw "docker $($Arguments[0]) failed with exit code $LASTEXITCODE" } +} + +$os = Invoke-Docker @('info', '--format', '{{.OSType}}') +if ($os -ne 'windows') { throw 'This build requires a Windows Docker engine (Windows Server 2022).' } + +Invoke-Docker @('build', '--isolation=process', '-f', "$worker/Dockerfile.base", '-t', 'scope-windows-base:ci', $root) +Invoke-Docker @('build', '--isolation=process', '-f', "$worker/Dockerfile.deps", '--build-arg', 'BASE_IMAGE=scope-windows-base:ci', '--build-arg', "COPILOT_CLI_VERSION=$version", '-t', 'scope-windows-deps:ci', $root) +Invoke-Docker @('build', '--isolation=process', '-f', "$worker/Dockerfile.windows", '--build-arg', 'DEPS_IMAGE=scope-windows-deps:ci', '--build-arg', "COPILOT_CLI_VERSION=$version", '-t', 'scope-copilot-windows:ci', $root) +Invoke-Docker @('run', '--rm', '--isolation=process', '--entrypoint', 'node', 'scope-copilot-windows:ci', '-e', "require('node:fs').accessSync('dist/index.js'); console.log(process.version)") diff --git a/scripts/build-windows-worker.test.ts b/scripts/build-windows-worker.test.ts new file mode 100644 index 00000000..4fa48681 --- /dev/null +++ b/scripts/build-windows-worker.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const script = resolve("scripts/build-windows-worker.ps1"); + +function runBuild(failAt = "", os = "windows") { + const directory = mkdtempSync(join(tmpdir(), "scope-windows-build-")); + const log = join(directory, "docker.jsonl"); + try { + const result = spawnSync("pwsh", ["-NoLogo", "-NoProfile", "-Command", ` + function docker { + Add-Content -Path $env:DOCKER_LOG -Value (ConvertTo-Json -InputObject @($args) -Compress) + $global:LASTEXITCODE = 0 + if ($env:FAIL_AT -and (($args -join ' ') -like "*$env:FAIL_AT*")) { + $global:LASTEXITCODE = 23 + return + } + if ($args[0] -eq 'info') { $env:DOCKER_OS } + } + & $env:BUILD_SCRIPT + `], { + env: { ...process.env, DOCKER_LOG: log, FAIL_AT: failAt, DOCKER_OS: os, BUILD_SCRIPT: script }, + encoding: "utf8", + timeout: 10000, + }); + if (result.error) throw result.error; + const calls = readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line) as string[]); + return { ...result, calls }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +describe("Windows local image validation", () => { + it("builds base, pinned dependencies and worker locally, then smoke-tests without publishing", () => { + const result = runBuild(); + expect(result.status, result.stderr).toBe(0); + expect(result.calls.map((args) => args[0])).toEqual(["info", "build", "build", "build", "run"]); + expect(result.calls[1]).toContain("scope-windows-base:ci"); + expect(result.calls[2]).toContain("BASE_IMAGE=scope-windows-base:ci"); + expect(result.calls[3]).toContain("DEPS_IMAGE=scope-windows-deps:ci"); + const version = readFileSync("apps/workers/coder-acp-copilot/versions.env", "utf8").match(/^COPILOT_CLI_VERSION=(.+)$/m)![1]; + expect(result.calls[2]).toContain(`COPILOT_CLI_VERSION=${version}`); + expect(result.calls[3]).toContain(`COPILOT_CLI_VERSION=${version}`); + expect(result.calls[4]).toContain("scope-copilot-windows:ci"); + expect(JSON.stringify(result.calls)).not.toMatch(/login|push|azurecr|scope-core/); + }); + + it.each(["info", "Dockerfile.base", "Dockerfile.deps", "Dockerfile.windows", "run"])("stops immediately on a failing docker %s", (stage) => { + const result = runBuild(stage); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("failed with exit code 23"); + expect(result.calls.at(-1)?.join(" ")).toContain(stage); + }); + + it("rejects a Linux Docker engine rather than pretending to validate Windows", () => { + const result = runBuild("", "linux"); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("requires a Windows Docker engine"); + expect(result.calls).toHaveLength(1); + }); + + it("propagates native installer and compilation failures from Windows PowerShell Docker RUN steps", () => { + for (const file of ["Dockerfile.base", "Dockerfile.deps", "Dockerfile.windows"]) { + const source = readFileSync(`apps/workers/coder-acp-copilot-windows/${file}`, "utf8"); + for (const line of source.split("\n").filter((line) => /(?:pnpm\.exe|npm|choco) (?:install|--filter)/.test(line))) { + expect(line, file).toContain("if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }"); + } + } + }); +}); diff --git a/scripts/ci-workflow.test.ts b/scripts/ci-workflow.test.ts index 72659c30..81afcfe2 100644 --- a/scripts/ci-workflow.test.ts +++ b/scripts/ci-workflow.test.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -13,20 +13,23 @@ const { parse }: { parse: (source: string) => unknown } = createRequire(resolve("packages/shared/package.json"))("yaml"); interface Step { + id?: string; name?: string; uses?: string; run?: string; if?: string; env?: Record; with?: Record; + shell?: string; } interface Job { if?: string; - needs?: string[]; + needs?: string | string[]; permissions?: Record; env?: Record; outputs?: Record; + "runs-on"?: string; steps: Step[]; strategy?: { matrix: { worker: { name: string; dockerfile: string; versions_env: string; test_pattern: string; images: string }[] } }; } @@ -83,7 +86,7 @@ describe("CI execution prerequisites", () => { it("parses the remaining inline shell scripts", () => { for (const job of Object.values(jobs)) { - for (const command of job.steps.filter((candidate) => candidate.run)) { + for (const command of job.steps.filter((candidate) => candidate.run && candidate.shell !== "pwsh")) { const script = command.run!.replace(/\$\{\{.*?\}\}/g, "placeholder"); const result = spawnSync("bash", ["-n"], { input: script, encoding: "utf8" }); expect(result.stderr, command.name).toBe(""); @@ -122,7 +125,7 @@ describe("CI repository and credential boundaries", () => { { repository: "growth-ecosystems/scope-core", integration: false }, { repository: "cedricvidal/scope", integration: false }, ])("selects public integration checks only in their owning repository: $repository", ({ repository, integration }) => { - for (const name of ["integration-test", "integration-test-queue"]) { + for (const name of ["integration-test", "integration-test-queue", "windows-build"]) { const gate = jobs[name].if!.match(/github\.repository == '([^']+)' &&/); expect(gate, `${name} must retain a mandatory repository gate`).not.toBeNull(); expect(gate![1] === repository, name).toBe(integration); @@ -158,7 +161,7 @@ describe("CI repository and credential boundaries", () => { for (const job of Object.values(jobs)) { expect(job.permissions?.["id-token"]).toBeUndefined(); expect(JSON.stringify(job)).not.toMatch(/azure\/login|az acr|ACR_NAME|scope-core/); - for (const dependency of job.needs ?? []) expect(jobs).toHaveProperty(dependency); + for (const dependency of typeof job.needs === "string" ? [job.needs] : job.needs ?? []) expect(jobs).toHaveProperty(dependency); } expect(jobs["llm-evals"].if).toContain(trusted); for (const name of ["test", "gateway"]) { @@ -169,7 +172,7 @@ describe("CI repository and credential boundaries", () => { }); it("removes internal-only automation without removing public Pages or repository maintenance", () => { - for (const file of ["build-windows-base.yml", "daily-repo-status.md", "daily-repo-status.lock.yml", "publish-cli.yml"]) { + for (const file of ["build-windows-base.yml", "daily-repo-status.md", "daily-repo-status.lock.yml"]) { expect(existsSync(join(".github/workflows", file)), file).toBe(false); } for (const file of ["static.yml", "gitleaks.yml", "check-worker-versions.yml", "daily-test-improver.md", "daily-test-improver.lock.yml", "worker-version-upgrade.md", "worker-version-upgrade.lock.yml"]) { @@ -180,4 +183,88 @@ describe("CI repository and credential boundaries", () => { expect(source, file).not.toMatch(/github\.repository == 'growth-ecosystems\/scope-core'|vars\.ACR_NAME/); } }); + + it("validates the full Windows chain on a public hosted runner and gates CI Summary", () => { + expect(jobs["windows-build"]["runs-on"]).toBe("windows-2022"); + expect(jobs["windows-build"].permissions).toEqual({ contents: "read" }); + expect(jobs["windows-build"].if).not.toContain("head.repo"); + expect(step("windows-build", "Build and smoke-test local Windows images").run).toBe("./scripts/build-windows-worker.ps1"); + expect(jobs["ci-summary"].needs).toContain("windows-build"); + expect(step("ci-summary", "Check overall status").run).toContain('needs.windows-build.result'); + for (const path of ["packages/telemetry/**", "scripts/build-windows-worker.ps1", "pnpm-workspace.yaml", ".dockerignore"]) { + const filters = jobs["detect-changes"].steps.find((candidate) => candidate.with?.filters)?.with?.filters; + expect(filters).toContain(path); + } + }); +}); + +describe("Public CLI release workflow", () => { + const release = parse(readFileSync(".github/workflows/publish-cli.yml", "utf8")) as { + on: Record; + concurrency: { group: string; "cancel-in-progress": boolean }; + permissions: Record; + jobs: Record; + }; + const versionStep = release.jobs.test.steps.find((candidate) => candidate.id === "version"); + + it("serializes manual, main-only releases with write permission isolated to publication", () => { + expect(Object.keys(release.on)).toEqual(["workflow_dispatch"]); + expect(release.concurrency).toEqual({ group: "publish-cli", "cancel-in-progress": false }); + expect(release.permissions).toEqual({ contents: "read" }); + for (const job of Object.values(release.jobs)) { + expect(job.if).toBe("github.repository == 'microsoft/scope' && github.ref == 'refs/heads/main'"); + } + expect(release.jobs.publish.needs).toBe("test"); + expect(release.jobs.publish.permissions).toEqual({ contents: "write" }); + const publish = release.jobs.publish.steps.find((candidate) => candidate.run)!; + expect(publish.env?.GH_TOKEN).toBe("${{ github.token }}"); + expect(publish.run).toContain('--repo "$GITHUB_REPOSITORY"'); + expect(publish.run).toContain('--target "$COMMIT"'); + expect(publish.env?.COMMIT).toBe("${{ github.sha }}"); + expect(JSON.stringify(release)).not.toMatch(/scope-core|scope-doc|FLUX|id-token|secrets\./); + expect(release.jobs.test.steps.find((candidate) => candidate.name === "Build CLI bundle")?.run).toBe("pnpm build:cli"); + expect(release.jobs.test.steps.find((candidate) => candidate.name === "Test release bundle")?.run).toContain("apps/cli/src/bundle.integration.test.ts"); + const upload = release.jobs.test.steps.find((candidate) => candidate.uses?.startsWith("actions/upload-artifact@")); + const download = release.jobs.publish.steps.find((candidate) => candidate.uses?.startsWith("actions/download-artifact@")); + expect(upload?.with?.path).toBe("apps/cli/dist/scope.mjs"); + expect(download?.with?.name).toBe(upload?.with?.name); + for (const job of Object.values(release.jobs)) { + for (const command of job.steps.filter((candidate) => candidate.run)) { + expect(spawnSync("bash", ["-n"], { input: command.run }).status, command.name).toBe(0); + } + } + }); + + it.each([ + { tags: "cli/v1.9.0\ncli/v1.10.0", bump: "patch", expected: "1.10.1" }, + { tags: "", bump: "minor", expected: "0.1.0", packageVersion: "0.0.0-dev" }, + { tags: "", bump: "minor", expected: "3.5.0", packageVersion: "3.4.5" }, + { tags: "", bump: "minor", expected: undefined, packageVersion: "invalid" }, + { tags: "cli/vbad", bump: "patch", expected: undefined }, + { tags: "cli/v1.0.0", bump: "invalid", expected: undefined }, + { tags: "", bump: "patch", expected: undefined, gitError: "1" }, + ])("resolves release versions without hiding invalid tags or git failures: $tags / $bump", ({ tags, bump, expected, gitError, packageVersion }) => { + const directory = mkdtempSync(join(tmpdir(), "scope-cli-release-")); + directories.push(directory); + writeFileSync(join(directory, "git"), '#!/bin/sh\nif [ "$TEST_GIT_ERROR" = "1" ]; then echo "git read failed" >&2; exit 1; fi\nprintf "%s" "$TEST_TAGS"\n', { mode: 0o755 }); + writeFileSync(join(directory, "package.json"), JSON.stringify({ version: packageVersion ?? "0.0.0-dev" })); + symlinkSync(resolve("apps/cli/node_modules"), join(directory, "node_modules"), "dir"); + const output = join(directory, "output"); + const versionScript = versionStep!.run!.match(/^node --input-type=module <<'NODE'\n([\s\S]+)\nNODE\n$/)![1]; + const result = spawnSync(process.execPath, ["--input-type=module"], { + input: versionScript, + cwd: directory, + env: { ...process.env, PATH: `${directory}:${process.env.PATH}`, TEST_TAGS: tags, TEST_GIT_ERROR: gitError ?? "", BUMP: bump, GITHUB_OUTPUT: output }, + encoding: "utf8", + timeout: 10000, + }); + if (expected) { + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(output, "utf8")).toBe(`version=${expected}\n`); + if (!tags) expect(result.stdout).toContain(`bootstrapping from package version ${packageVersion}`); + } else { + expect(result.status).not.toBe(0); + expect(existsSync(output)).toBe(false); + } + }); }); diff --git a/scripts/install-cli.test.ts b/scripts/install-cli.test.ts new file mode 100644 index 00000000..eab10c4b --- /dev/null +++ b/scripts/install-cli.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const installer = resolve("install-cli.sh"); +const curlFixture = `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +const url = args.find(arg => arg.startsWith('https://')); +fs.appendFileSync(process.env.CURL_LOG, url + '\\n'); +if (url === 'https://api.github.com/repos/microsoft/scope/releases') { + if (process.env.RELEASE_ERROR) { console.error('Release request failed'); process.exit(22); } + console.log(process.env.RELEASES); +} else if (url === 'https://github.com/microsoft/scope/releases/download/cli%2Fv3.2.1/scope.mjs') { + if (process.env.DOWNLOAD_ERROR) { console.error('Asset download failed'); process.exit(22); } + fs.writeFileSync(args[args.indexOf('--output') + 1], '#!/usr/bin/env node\\nconsole.log("' + process.env.BUNDLE_VERSION + '");\\n'); +} else { console.error('Unexpected URL: ' + url); process.exit(1); } +`; + +describe("Public CLI installer", () => { + it.each([ + { name: "success", success: true }, + { name: "missing release", releases: "[]", message: "No published Scope CLI release" }, + { name: "API error", releaseError: "1", message: "Release request failed" }, + { name: "download error", downloadError: "1", message: "Asset download failed" }, + { name: "wrong artifact version", version: "3.2.0", message: "does not match" }, + ])("handles $name without touching a real installation", ({ success, releases, releaseError, downloadError, version, message }) => { + const directory = mkdtempSync(join(tmpdir(), "scope-install-test-")); + const installDir = join(directory, "bin with spaces"); + const log = join(directory, "curl.log"); + mkdirSync(installDir); + writeFileSync(join(installDir, "scope"), "existing installation"); + writeFileSync(join(directory, "curl"), curlFixture, { mode: 0o755 }); + try { + const result = spawnSync("bash", [installer], { + env: { + ...process.env, PATH: `${directory}:${process.env.PATH}`, SCOPE_INSTALL_DIR: installDir, + CURL_LOG: log, RELEASE_ERROR: releaseError ?? "", DOWNLOAD_ERROR: downloadError ?? "", + BUNDLE_VERSION: version ?? "3.2.1", + RELEASES: releases ?? JSON.stringify([ + { tag_name: "cli/v9.0.0", draft: true }, + { tag_name: "other/v4.0.0" }, + { tag_name: "cli/v4.0.0-beta.1", prerelease: true }, + { tag_name: "cli/v3.2.1" }, + ]), + }, + encoding: "utf8", + timeout: 10000, + }); + const installed = readFileSync(join(installDir, "scope"), "utf8"); + if (success) { + expect(result.status, result.stderr).toBe(0); + expect(installed).toContain('console.log("3.2.1")'); + expect(statSync(join(installDir, "scope")).mode & 0o777).toBe(0o755); + } else { + expect(result.status).not.toBe(0); + expect(result.stderr).toContain(message); + expect(installed).toBe("existing installation"); + } + expect(readdirSync(installDir)).toEqual(["scope"]); + expect(readFileSync(log, "utf8")).not.toMatch(/scope-core|scope-doc/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); From 8bffe1f24c30f39cb5247162ad93fe3edcba2a0c Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 29 Sep 2026 12:09:10 -0700 Subject: [PATCH 7/8] fix(docs): route website CLI installs to public releases Use the canonical public installer throughout onboarding and delegate the website compatibility entry point to it. Preserve API access requirements and verify anonymous installs, failure safety, partial-download rejection and rendered public website links. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 3 + docs/architecture/cli-distribution.md | 5 + scripts/install-cli.test.ts | 68 +++++++++- website/README.md | 9 +- website/install-cli.sh | 128 ++---------------- .../content/docs/getting-started/access.md | 6 +- .../docs/getting-started/install-cli.md | 29 ++-- 7 files changed, 110 insertions(+), 138 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19887524..b188c621 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ on: - ".github/workflows/publish-cli.yml" - ".dockerignore" - "install-cli.sh" + - "website/install-cli.sh" pull_request: branches: [main] paths: @@ -35,6 +36,7 @@ on: - ".github/workflows/publish-cli.yml" - ".dockerignore" - "install-cli.sh" + - "website/install-cli.sh" workflow_dispatch: permissions: @@ -845,6 +847,7 @@ jobs: - 'scripts/build-windows-worker.test.ts' - 'scripts/build-windows-worker.ps1' - 'install-cli.sh' + - 'website/install-cli.sh' - '.dockerignore' - 'pnpm-workspace.yaml' - 'apps/**' diff --git a/docs/architecture/cli-distribution.md b/docs/architecture/cli-distribution.md index 4cd84a7a..25b0e98e 100644 --- a/docs/architecture/cli-distribution.md +++ b/docs/architecture/cli-distribution.md @@ -129,6 +129,11 @@ directory). Missing releases, API/download errors and version mismatches fail explicitly without replacing an existing installation. Installation does not require GitHub authentication; public API rate limits still apply. +The published website's onboarding pages use this same URL. +`website/install-cli.sh` remains a compatibility entry point: it downloads +the canonical root script completely before running it, rather than +maintaining another release lookup or installation implementation. + `scope update` retains its `gh release download` implementation, so updating in-place requires `gh` configured with GitHub authentication. Alternatively, rerun the public installer without `gh`. diff --git a/scripts/install-cli.test.ts b/scripts/install-cli.test.ts index eab10c4b..7636385c 100644 --- a/scripts/install-cli.test.ts +++ b/scripts/install-cli.test.ts @@ -13,7 +13,15 @@ const fs = require('node:fs'); const args = process.argv.slice(2); const url = args.find(arg => arg.startsWith('https://')); fs.appendFileSync(process.env.CURL_LOG, url + '\\n'); -if (url === 'https://api.github.com/repos/microsoft/scope/releases') { +if (url === 'https://raw.githubusercontent.com/microsoft/scope/main/install-cli.sh') { + const output = args[args.indexOf('--output') + 1]; + if (process.env.INSTALLER_ERROR) { + fs.writeFileSync(output, 'echo damaged > "$SCOPE_INSTALL_DIR/scope"\\n'); + console.error('Installer download failed'); + process.exit(22); + } + fs.copyFileSync(process.env.ROOT_INSTALLER, output); +} else if (url === 'https://api.github.com/repos/microsoft/scope/releases') { if (process.env.RELEASE_ERROR) { console.error('Release request failed'); process.exit(22); } console.log(process.env.RELEASES); } else if (url === 'https://github.com/microsoft/scope/releases/download/cli%2Fv3.2.1/scope.mjs') { @@ -22,7 +30,7 @@ if (url === 'https://api.github.com/repos/microsoft/scope/releases') { } else { console.error('Unexpected URL: ' + url); process.exit(1); } `; -describe("Public CLI installer", () => { +describe.each(["install-cli.sh", "website/install-cli.sh"])("Public CLI installer: %s", (entryPoint) => { it.each([ { name: "success", success: true }, { name: "missing release", releases: "[]", message: "No published Scope CLI release" }, @@ -36,10 +44,14 @@ describe("Public CLI installer", () => { mkdirSync(installDir); writeFileSync(join(installDir, "scope"), "existing installation"); writeFileSync(join(directory, "curl"), curlFixture, { mode: 0o755 }); + writeFileSync(join(directory, "gh"), '#!/bin/sh\necho "Unexpected GitHub authentication dependency" >&2\nexit 1\n', { mode: 0o755 }); try { - const result = spawnSync("bash", [installer], { + const result = spawnSync("bash", [], { + input: readFileSync(entryPoint, "utf8"), + cwd: directory, env: { ...process.env, PATH: `${directory}:${process.env.PATH}`, SCOPE_INSTALL_DIR: installDir, + GH_TOKEN: "", GITHUB_TOKEN: "", ROOT_INSTALLER: installer, INSTALLER_ERROR: "", CURL_LOG: log, RELEASE_ERROR: releaseError ?? "", DOWNLOAD_ERROR: downloadError ?? "", BUNDLE_VERSION: version ?? "3.2.1", RELEASES: releases ?? JSON.stringify([ @@ -63,9 +75,57 @@ describe("Public CLI installer", () => { expect(installed).toBe("existing installation"); } expect(readdirSync(installDir)).toEqual(["scope"]); - expect(readFileSync(log, "utf8")).not.toMatch(/scope-core|scope-doc/); + const requests = readFileSync(log, "utf8").trim().split("\n"); + expect(requests[0]).toBe(entryPoint.startsWith("website/") + ? "https://raw.githubusercontent.com/microsoft/scope/main/install-cli.sh" + : "https://api.github.com/repos/microsoft/scope/releases"); + expect(requests.join("\n")).not.toMatch(/scope-core|scope-doc/); } finally { rmSync(directory, { recursive: true, force: true }); } }); }); + +it("does not execute a partial website installer download or replace an existing installation", () => { + const directory = mkdtempSync(join(tmpdir(), "scope-install-bootstrap-test-")); + const installDir = join(directory, "bin"); + const temporary = join(directory, "tmp"); + mkdirSync(installDir); + mkdirSync(temporary); + writeFileSync(join(installDir, "scope"), "existing installation"); + writeFileSync(join(directory, "curl"), curlFixture, { mode: 0o755 }); + try { + const result = spawnSync("bash", [], { + input: readFileSync("website/install-cli.sh", "utf8"), + cwd: directory, + env: { + ...process.env, PATH: `${directory}:${process.env.PATH}`, SCOPE_INSTALL_DIR: installDir, + TMPDIR: temporary, CURL_LOG: join(directory, "curl.log"), INSTALLER_ERROR: "1", + GH_TOKEN: "", GITHUB_TOKEN: "", + }, + encoding: "utf8", + timeout: 10000, + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Installer download failed"); + expect(readFileSync(join(installDir, "scope"), "utf8")).toBe("existing installation"); + expect(readdirSync(temporary)).toEqual([]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +it("routes published onboarding examples to the public canonical installer", () => { + for (const file of ["install-cli.md", "access.md"]) { + const content = readFileSync(`website/src/content/docs/getting-started/${file}`, "utf8"); + expect(content).toContain("https://raw.githubusercontent.com/microsoft/scope/main/install-cli.sh"); + expect(content).not.toMatch(/growth-ecosystems|scope-doc|GH_TOKEN|GITHUB_TOKEN/); + } + expect(readFileSync("website/src/content/docs/getting-started/install-cli.md", "utf8")) + .toContain("| SCOPE_INSTALL_DIR=~/bin bash"); + expect(readFileSync("website/src/content/docs/getting-started/access.md", "utf8")) + .toContain("your deployment's API still requires its configured authentication"); + const compatibilityScript = readFileSync("website/install-cli.sh", "utf8"); + expect(compatibilityScript).not.toContain("api.github.com/repos"); + expect(compatibilityScript).toContain("--retry 3"); +}); diff --git a/website/README.md b/website/README.md index f77bba01..c82223ff 100644 --- a/website/README.md +++ b/website/README.md @@ -267,10 +267,11 @@ Recommended content follow-ups: - Walk the first-run guide against a current deployment, then add maintained screenshots. Its claims about preseeded catalogs, model availability, and UI labels should not be assumed for every deployment. -- Explain release-repository access during CLI onboarding. The legacy - `growth-ecosystems/scope-doc` reference is still used by the installer - and publishing workflow, so changing it just because the documentation - moved would be incorrect. A release migration is a separate change. +- CLI releases now come from the public `microsoft/scope` repository. + Onboarding uses the canonical root installer; the website installer + is a compatibility entry point that downloads and runs that same + script. Public installation needs Node.js and curl, not GitHub + authentication. Deployment/API access requirements remain separate. - Add a real, reproducible sample-results walkthrough when an approved dataset is available. Keep real evidence separate from the example. diff --git a/website/install-cli.sh b/website/install-cli.sh index 36d397f0..bab47c2a 100644 --- a/website/install-cli.sh +++ b/website/install-cli.sh @@ -1,122 +1,14 @@ #!/usr/bin/env bash -# Scope CLI installer -# -# Usage: -# gh api repos/growth-ecosystems/scope-doc/contents/install-cli.sh -H "Accept: application/vnd.github.raw" | bash -# -# Requires: node (>= 20) and either `gh` CLI (authenticated) or GH_TOKEN/GITHUB_TOKEN. -# -# Installs to ~/.local/bin/scope by default. Override with SCOPE_INSTALL_DIR. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Compatibility entry point; installation behavior lives in the root installer. set -euo pipefail -REPO="growth-ecosystems/scope-doc" -TAG_PREFIX="cli/v" -INSTALL_DIR="${SCOPE_INSTALL_DIR:-$HOME/.local/bin}" -BINARY_NAME="scope" - -# --- Helpers --- - -info() { printf "\033[1;34m→\033[0m %s\n" "$*"; } -success() { printf "\033[1;32m✓\033[0m %s\n" "$*"; } -error() { printf "\033[1;31m✗\033[0m %s\n" "$*" >&2; exit 1; } - -# --- Prerequisite checks --- - -command -v node >/dev/null 2>&1 || error "Node.js is required (>= 20). Install from https://nodejs.org" - -NODE_MAJOR=$(node -e "process.stdout.write(String(process.versions.node.split('.')[0]))") -if [ "$NODE_MAJOR" -lt 20 ]; then - error "Node.js >= 20 is required (found v$(node --version))" -fi - -# Prefer gh CLI if available (handles auth automatically) -if command -v gh >/dev/null 2>&1; then - HAS_GH=1 -else - HAS_GH=0 - # Need a token for API access to private repo - TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}" - if [ -z "$TOKEN" ]; then - error "GitHub token required. Set GH_TOKEN or GITHUB_TOKEN, or install the gh CLI." - fi -fi - -# --- Find latest CLI release (matching cli/v* tag) --- - -info "Fetching latest CLI release from $REPO..." - -if [ "$HAS_GH" = "1" ]; then - RELEASE_JSON=$(gh api "repos/$REPO/releases" --paginate 2>/dev/null | node -e " - const releases = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); - const match = (Array.isArray(releases) ? releases : []).find(r => (r.tag_name || '').startsWith('${TAG_PREFIX}')); - if (match) process.stdout.write(JSON.stringify(match)); - else process.exit(1); - ") || error "No CLI release found (no release with ${TAG_PREFIX}* tag). Run 'gh auth login' if not authenticated." -else - RELEASE_JSON=$(curl -fsSL \ - -H "Authorization: token $TOKEN" \ - -H "Accept: application/vnd.github.v3+json" \ - "https://api.github.com/repos/$REPO/releases" 2>/dev/null | node -e " - const releases = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); - const match = (Array.isArray(releases) ? releases : []).find(r => (r.tag_name || '').startsWith('${TAG_PREFIX}')); - if (match) process.stdout.write(JSON.stringify(match)); - else process.exit(1); - ") || error "No CLI release found. Check your token or create a release with a ${TAG_PREFIX}* tag." -fi - -# Parse version and asset URL -VERSION=$(echo "$RELEASE_JSON" | node -e " - const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); - process.stdout.write((d.tag_name || '').replace(/^cli\/v/, '')); -") -ASSET_URL=$(echo "$RELEASE_JSON" | node -e " - const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); - const asset = (d.assets || []).find(a => a.name === 'scope.mjs'); - if (asset) process.stdout.write(asset.url || ''); -") - -if [ -z "$VERSION" ]; then - error "Could not determine latest version" -fi -if [ -z "$ASSET_URL" ]; then - error "Could not find scope.mjs asset in release $VERSION" -fi - -info "Installing scope $VERSION..." - -# --- Download --- - -mkdir -p "$INSTALL_DIR" - -if [ "$HAS_GH" = "1" ]; then - # gh handles authentication automatically - gh api "$ASSET_URL" -H "Accept: application/octet-stream" > "$INSTALL_DIR/$BINARY_NAME" 2>/dev/null || \ - error "Failed to download asset" -else - curl -fsSL \ - -H "Authorization: token $TOKEN" \ - -H "Accept: application/octet-stream" \ - "$ASSET_URL" \ - -o "$INSTALL_DIR/$BINARY_NAME" || error "Failed to download asset" -fi - -chmod +x "$INSTALL_DIR/$BINARY_NAME" - -# --- Verify --- - -INSTALLED_VERSION=$("$INSTALL_DIR/$BINARY_NAME" --version 2>/dev/null) || error "Installation verification failed" -[ -n "$INSTALLED_VERSION" ] || error "Installation verification failed: --version returned empty output" - -success "Installed scope $INSTALLED_VERSION to $INSTALL_DIR/$BINARY_NAME" - -# --- PATH check --- - -if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then - echo "" - info "Add $INSTALL_DIR to your PATH:" - echo "" - echo " # Add to ~/.bashrc, ~/.zshrc, or ~/.profile:" - echo " export PATH=\"$INSTALL_DIR:\$PATH\"" - echo "" -fi +command -v curl >/dev/null || { echo "curl is required." >&2; exit 1; } +temp_dir="$(mktemp -d)" +trap 'rm -f "$temp_dir/install-cli.sh"; rmdir "$temp_dir"' EXIT +curl --fail --silent --show-error --location --retry 3 \ + https://raw.githubusercontent.com/microsoft/scope/main/install-cli.sh \ + --output "$temp_dir/install-cli.sh" +bash "$temp_dir/install-cli.sh" diff --git a/website/src/content/docs/getting-started/access.md b/website/src/content/docs/getting-started/access.md index d2df9aef..cf2a0f96 100644 --- a/website/src/content/docs/getting-started/access.md +++ b/website/src/content/docs/getting-started/access.md @@ -40,10 +40,12 @@ for the required setup. For terminal-based workflows, install the `scope` CLI: ```bash -gh api repos/growth-ecosystems/scope-doc/contents/install-cli.sh \ - -H "Accept: application/vnd.github.raw" | bash +curl --fail --location https://raw.githubusercontent.com/microsoft/scope/main/install-cli.sh | bash ``` +Installing the public CLI does not require GitHub authentication. Using +your deployment's API still requires its configured authentication. + See [Install the CLI](/getting-started/install-cli/) for details. ## Next steps diff --git a/website/src/content/docs/getting-started/install-cli.md b/website/src/content/docs/getting-started/install-cli.md index 5c7f164a..2d05af5a 100644 --- a/website/src/content/docs/getting-started/install-cli.md +++ b/website/src/content/docs/getting-started/install-cli.md @@ -9,27 +9,33 @@ and manage profiles — all from your terminal. ## Prerequisites - **Node.js ≥ 20** — [nodejs.org](https://nodejs.org) -- **GitHub CLI (`gh`)** — authenticated with access to the - `growth-ecosystems/scope-doc` repo. - Alternatively, set a `GH_TOKEN` or `GITHUB_TOKEN` environment - variable with `repo` scope. +- **curl** — no GitHub authentication or private repository access is + required to install the public CLI. + +Using the CLI with a Scope deployment is separate from installing it: +your deployment administrator provides the API URL and any required +credentials. See [Access](/getting-started/access/). ## One-liner install ```bash -gh api repos/growth-ecosystems/scope-doc/contents/install-cli.sh \ - -H "Accept: application/vnd.github.raw" | bash +curl --fail --location https://raw.githubusercontent.com/microsoft/scope/main/install-cli.sh | bash ``` -This downloads and installs the latest release to +This downloads and installs the latest published, non-prerelease `cli/v*` +release from `microsoft/scope` to `~/.local/bin/scope`. Override the location with the `SCOPE_INSTALL_DIR` environment variable: ```bash -gh api repos/growth-ecosystems/scope-doc/contents/install-cli.sh \ - -H "Accept: application/vnd.github.raw" | SCOPE_INSTALL_DIR=~/bin bash +curl --fail --location https://raw.githubusercontent.com/microsoft/scope/main/install-cli.sh | SCOPE_INSTALL_DIR=~/bin bash ``` +The installer checks the downloaded bundle's version before replacing an +existing installation. Missing releases, download errors, and version +mismatches fail without replacing your installed CLI. If no public CLI +release has been published yet, the installer reports that explicitly. + ## Add to PATH If `~/.local/bin` is not already on your `PATH`, add it to your @@ -57,7 +63,10 @@ scope update ``` This downloads and installs the latest `cli/v*` release, -replacing the current binary in place. +replacing the current binary in place. This update command requires the +GitHub CLI (`gh`) installed and authenticated (`gh auth login`), but no +private repository access. Alternatively, rerun the public installer +above without GitHub authentication. ## What's next From d60a71e495972f17fb514280547a129477e6dfcf Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 29 Sep 2026 14:47:31 -0700 Subject: [PATCH 8/8] fix(cli): isolate standalone bundle and require an explicit API URL Prevent shared server imports from leaking into the bundle and isolate bundle subprocess tests from workspace module resolution. Remove built-in and port-derived API destinations while keeping help, version, and updates usable without configuration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ENV_VARIABLES.md | 8 ++- apps/cli/README.md | 12 +++- apps/cli/build.ts | 3 - apps/cli/src/bundle.integration.test.ts | 66 ++++++++++++++++--- apps/cli/src/commands/criteria.ts | 2 +- apps/cli/src/commands/run.ts | 2 +- apps/cli/src/index.ts | 13 +--- apps/cli/src/run-get-action.ts | 2 +- apps/cli/src/utils/api-client.test.ts | 8 +++ apps/cli/src/utils/api-client.ts | 2 +- apps/cli/src/utils/gates.ts | 5 +- apps/cli/src/utils/shared.test.ts | 45 +++++-------- apps/cli/src/utils/shared.ts | 48 ++++---------- .../utils/update-check.integration.test.ts | 16 ++++- docs/architecture/cli-distribution.md | 36 +++++++++- packages/shared/package.json | 10 +++ 16 files changed, 174 insertions(+), 104 deletions(-) diff --git a/ENV_VARIABLES.md b/ENV_VARIABLES.md index 9ce760b6..95057474 100644 --- a/ENV_VARIABLES.md +++ b/ENV_VARIABLES.md @@ -5,10 +5,14 @@ The sophisticated criteria system can be configured via environment variables in ## CLI Configuration ### SCOPE_API_URL -**Default:** `http://localhost:3100` +**Default:** None **Type:** URL string -Base URL of the Scope API used by all CLI commands. Override this to point the CLI at a remote or Docker-hosted API instance. +Base URL of the Scope API used by CLI API operations. Set this explicitly or pass +`-u/--url` to a command (`--api-url` for MCP server create/update). Both bundled +and source-mode CLIs fail before making an API request when no URL is configured. +Help, version, and CLI updates do not require a Scope API URL. The CLI no longer +derives a localhost URL from `SCOPE_API_PORT` or uses `SCOPE_DEFAULT_API_URL`. ## Docker Development diff --git a/apps/cli/README.md b/apps/cli/README.md index 00c057bf..1da7085c 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -22,7 +22,8 @@ export PATH="$HOME/.local/bin:$PATH" ## Configuration -Set the API URL to your Scope instance: +There is no default API URL in either the installed CLI or source-mode +development. Set the URL of your Scope instance explicitly: ```bash export SCOPE_API_URL=https://your-scope-api.example.com @@ -34,6 +35,12 @@ Or pass it per-command with `-u`: scope run list -u https://your-scope-api.example.com ``` +API operations fail with configuration guidance when no URL is supplied. +`--help`, `--version`, and `scope update` do not need a Scope API URL. +`SCOPE_DEFAULT_API_URL` and `SCOPE_API_PORT` no longer select an API destination. +For local development, set `SCOPE_API_URL=http://localhost:` +explicitly (in your environment or `.env`). + ### Authentication Set `SCOPE_TOKEN` to an IdP access token obtained for your API's audience. The CLI @@ -110,8 +117,7 @@ export SCOPE_NO_UPDATE_CHECK=1 | Variable | Description | |----------|-------------| -| `SCOPE_API_URL` | Default API base URL | -| `SCOPE_API_PORT` | Derive API URL as `http://localhost:$PORT` when `SCOPE_API_URL` is unset | +| `SCOPE_API_URL` | API base URL; required for API operations unless `-u` is provided. No default | | `SCOPE_TOKEN` | Caller-provided IdP access token for authenticated API calls; new identities must explicitly enroll | | `SCOPE_NO_UPDATE_CHECK` | Set to `1` to suppress update notifications | | `GH_TOKEN` / `GITHUB_TOKEN` | GitHub token for authenticated API calls (update checks, install script) | diff --git a/apps/cli/build.ts b/apps/cli/build.ts index 75a666fa..4b8a25f0 100644 --- a/apps/cli/build.ts +++ b/apps/cli/build.ts @@ -55,9 +55,6 @@ await build({ banner: { js: banner }, define: { "process.env.SCOPE_CLI_VERSION": JSON.stringify(pkg.version), - "process.env.SCOPE_DEFAULT_API_URL": JSON.stringify( - process.env.SCOPE_DEFAULT_API_URL || "https://msscope.azurewebsites.net" - ), }, external: [], logLevel: "warning", diff --git a/apps/cli/src/bundle.integration.test.ts b/apps/cli/src/bundle.integration.test.ts index d295353c..11d8e071 100644 --- a/apps/cli/src/bundle.integration.test.ts +++ b/apps/cli/src/bundle.integration.test.ts @@ -14,7 +14,7 @@ const BUNDLE_PATH = resolve(import.meta.dirname, "../dist/scope.mjs"); if (!existsSync(BUNDLE_PATH)) { throw new Error( - `Bundle not found at ${BUNDLE_PATH}. Run "pnpm build:bundle" first.` + `Bundle not found at ${BUNDLE_PATH}. Run "pnpm build:cli" first.` ); } @@ -42,8 +42,13 @@ const MOCK_RESPONSES: Record = { let server: Server; let port: number; +let bundleDir: string; +let isolatedBundle: string; beforeAll(async () => { + bundleDir = mkdtempSync(join(tmpdir(), "scope-bundle-")); + isolatedBundle = join(bundleDir, "scope.mjs"); + copyFileSync(BUNDLE_PATH, isolatedBundle); server = createServer((req, res) => { const url = new URL(req.url ?? "/", `http://localhost`); const response = MOCK_RESPONSES[url.pathname]; @@ -65,15 +70,20 @@ beforeAll(async () => { afterAll(() => { server?.close(); + if (bundleDir) rmSync(bundleDir, { recursive: true, force: true }); }); -async function runScope(...args: string[]): Promise<{ stdout: string; stderr: string }> { - const { stdout, stderr } = await execFileAsync("node", [BUNDLE_PATH, ...args], { +async function runScope(args: string[], env: NodeJS.ProcessEnv = {}): Promise<{ stdout: string; stderr: string }> { + const { stdout, stderr } = await execFileAsync(process.execPath, ["--no-global-search-paths", isolatedBundle, ...args], { + cwd: bundleDir, env: { ...process.env, + NODE_PATH: "", + NODE_OPTIONS: "", SCOPE_NO_UPDATE_CHECK: "1", SCOPE_API_URL: `http://127.0.0.1:${port}`, SCOPE_PROJECT: "test-project", + ...env, }, timeout: 10000, }); @@ -82,46 +92,82 @@ async function runScope(...args: string[]): Promise<{ stdout: string; stderr: st describe("Bundle integration tests", () => { it("--version prints the version", async () => { - const { stdout } = await runScope("--version"); + const { stdout } = await runScope(["--version"], { SCOPE_API_URL: "" }); expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); }); it("--help shows usage information", async () => { - const { stdout } = await runScope("--help"); + const { stdout } = await runScope(["--help"], { SCOPE_API_URL: "" }); expect(stdout).toContain("scope"); expect(stdout).toContain("Scope — The AI Agentic Experience Evaluation Platform"); expect(stdout).not.toContain("MS Scope"); expect(stdout).toContain("run"); expect(stdout).toContain("criteria"); + expect(stdout).not.toContain("https://msscope.azurewebsites.net"); + expect(stdout).not.toContain("http://localhost:3100"); }); it("run list fetches from mock API and formats output", async () => { - const { stdout } = await runScope("run", "list", "-u", `http://127.0.0.1:${port}`); + const { stdout } = await runScope(["run", "list"]); expect(stdout).toContain("req-test-001"); expect(stdout).toContain("coder-acp-copilot"); expect(stdout).toContain("done"); }); it("criteria list fetches from mock API", async () => { - const { stdout } = await runScope("criteria", "list", "-u", `http://127.0.0.1:${port}`); + const { stdout } = await runScope(["criteria", "list", "-u", `http://127.0.0.1:${port}`], { + SCOPE_API_URL: "http://127.0.0.1:1", + }); expect(stdout).toContain("has_button"); }); it("run list --output json returns valid JSON", async () => { - const { stdout } = await runScope("run", "list", "-u", `http://127.0.0.1:${port}`, "-o", "json"); + const { stdout } = await runScope(["run", "list", "-u", `http://127.0.0.1:${port}`, "-o", "json"]); const parsed = JSON.parse(stdout); expect(Array.isArray(parsed)).toBe(true); expect(parsed[0].id).toBe("req-test-001"); }); + it("accepts an explicit URL without environment configuration", async () => { + const { stdout } = await runScope(["run", "list", "--url", `http://127.0.0.1:${port}`], { + SCOPE_API_URL: "", + }); + expect(stdout).toContain("req-test-001"); + }); + + it.each([ + ["project", "list"], + ["run", "list"], + ["run", "logs", "-i", "req-test-001"], + ["run", "submit", "-m", "Test task", "-w", "coder-acp-copilot", "--no-stream"], + ["criteria", "export"], + ["mcp", "server", "list"], + ["mcp", "server", "create", "--id", "test", "--name", "Test", "--type", "http", "--url", "https://mcp.example.com"], + ])("requires an API URL for %j", async (...args) => { + await expect(runScope(args, { + SCOPE_API_URL: "", + SCOPE_DEFAULT_API_URL: "http://127.0.0.1:1", + SCOPE_API_PORT: "1", + })).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("No API URL configured. Set SCOPE_API_URL or pass -u/--url"), + }); + }); + + it("keeps update help usable without an API URL", async () => { + const { stdout } = await runScope(["update", "--help"], { SCOPE_API_URL: "" }); + expect(stdout).toContain("update"); + }); + it("works when installed as 'scope' (no .mjs extension)", async () => { const tempDir = mkdtempSync(join(tmpdir(), "scope-test-")); const scopeBin = join(tempDir, "scope"); try { copyFileSync(BUNDLE_PATH, scopeBin); chmodSync(scopeBin, 0o755); - const { stdout } = await execFileAsync("node", [scopeBin, "--version"], { - env: { ...process.env, SCOPE_NO_UPDATE_CHECK: "1" }, + const { stdout } = await execFileAsync(process.execPath, ["--no-global-search-paths", scopeBin, "--version"], { + cwd: tempDir, + env: { ...process.env, NODE_PATH: "", NODE_OPTIONS: "", SCOPE_API_URL: "", SCOPE_NO_UPDATE_CHECK: "1" }, timeout: 10000, }); expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); diff --git a/apps/cli/src/commands/criteria.ts b/apps/cli/src/commands/criteria.ts index 7406fe77..e59d0d79 100644 --- a/apps/cli/src/commands/criteria.ts +++ b/apps/cli/src/commands/criteria.ts @@ -345,7 +345,7 @@ criteria .description("Export criteria as import-compatible multi-document YAML") .option("--ids ", "Export only these criteria and their dependency ancestors") .option("-o, --output-file ", "Write to file instead of stdout") - .option("-u, --url ", "API base URL", process.env.SCOPE_API_URL || "http://localhost:3100") + .option("-u, --url ", "API base URL", getDefaultApiUrl()) .option("--project ", "Project ID for scoped operations (overrides SCOPE_PROJECT and the saved selection)") .action(async (options) => { try { diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 51aba1f5..176d5896 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -64,7 +64,7 @@ run .option("--profile-variations-file ", "Path to JSON file containing profile variation entries") .option("--agents-md ", "AGENTS.md content delivered to the workspace (prefix with @ to read from a file)") .option("--gates ", "GateConfig[] JSON or path/@path to a JSON file for gated runs") - .option("-u, --url ", "API base URL", process.env.SCOPE_API_URL || "http://localhost:3100") + .option("-u, --url ", "API base URL", getDefaultApiUrl()) .option("--project ", "Project ID for scoped operations (overrides SCOPE_PROJECT and the saved selection)") .option("--no-stream", "Don't stream logs, just submit") .action(async (options, command) => { diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 63353909..4089536b 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -8,7 +8,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { Command } from "commander"; import { configureHelp, generateOutputFormatsHelp, generateEnvVarsHelp } from "./utils/helpFormatter.js"; -import { OUTPUT_FORMATS, ENV_VARS, applyApiPortFallback, getCliName } from "./utils/shared.js"; +import { OUTPUT_FORMATS, ENV_VARS, getCliName } from "./utils/shared.js"; import { registerRunCommands } from "./commands/run.js"; import { registerCriteriaCommands } from "./commands/criteria.js"; import { registerPromptFeatureCommands } from "./commands/prompt-feature.js"; @@ -33,8 +33,8 @@ const CLI_VERSION = process.env.SCOPE_CLI_VERSION ?? "0.1.0-dev"; /** * Walk up from `start` looking for a `.env` file, stopping at the first hit * or at the filesystem root. Lets `pnpm cli ...` (which sets cwd to apps/cli) - * still pick up the workspace-root `.env` produced by `worktree-env`, where - * variables like SCOPE_API_PORT actually live. + * still pick up explicit configuration such as SCOPE_API_URL from the + * workspace-root `.env`. */ function findEnvFile(start: string): string | undefined { let dir = resolve(start); @@ -50,13 +50,6 @@ function findEnvFile(start: string): string | undefined { const envPath = findEnvFile(process.cwd()); dotenv.config(envPath ? { path: envPath } : undefined); -// If SCOPE_API_URL is not already set but SCOPE_API_PORT is (e.g. when the API -// is running locally on a non-default port via docker-compose), derive a -// default SCOPE_API_URL of http://localhost:$SCOPE_API_PORT. Must run before -// any command module captures `process.env.SCOPE_API_URL` as its option -// default. -applyApiPortFallback(); - export const program = new Command(); program diff --git a/apps/cli/src/run-get-action.ts b/apps/cli/src/run-get-action.ts index cf28cbb3..1715dff6 100644 --- a/apps/cli/src/run-get-action.ts +++ b/apps/cli/src/run-get-action.ts @@ -5,7 +5,7 @@ * Action handler for `run get` subcommand — extracted for testability. */ import { colorLevel, dimTimestamp, errorText, successText, label, value, banner, warnBanner, criterionIcon } from "./utils/style.js"; -import { GATE_METADATA, GATE_ORDER, type ConversationTurn, type GateId, type GateRunSummary, type RequestDocument } from "shared"; +import { GATE_METADATA, GATE_ORDER, type ConversationTurn, type GateId, type GateRunSummary, type RequestDocument } from "shared/types"; import { formatData, isMachineReadable } from "./utils/formatters.js"; import type { OutputFormat, DisplayField } from "./utils/types.js"; import { apiFetch } from "./utils/api-client.js"; diff --git a/apps/cli/src/utils/api-client.test.ts b/apps/cli/src/utils/api-client.test.ts index 8f619ea3..7793f292 100644 --- a/apps/cli/src/utils/api-client.test.ts +++ b/apps/cli/src/utils/api-client.test.ts @@ -44,6 +44,14 @@ describe("apiFetch URL handling", () => { resetApiClient(); }); + it.each([undefined, "", " "])("rejects missing URL %j before any network request", async (url) => { + const mock = vi.fn(); + vi.stubGlobal("fetch", mock); + + await expect(apiFetch(url, "/projects")).rejects.toThrow("No API URL configured"); + expect(mock).not.toHaveBeenCalled(); + }); + it("joins base + path and normalizes trailing slashes", async () => { const mock = vi.fn().mockResolvedValue(okJson()); vi.stubGlobal("fetch", mock); diff --git a/apps/cli/src/utils/api-client.ts b/apps/cli/src/utils/api-client.ts index e48f2f07..79a26f55 100644 --- a/apps/cli/src/utils/api-client.ts +++ b/apps/cli/src/utils/api-client.ts @@ -472,7 +472,7 @@ function getClient(): KyInstance { * @returns The `fetch` `Response`. Callers keep their existing * `response.ok` / `response.json()` / streaming handling. */ -export async function apiFetch(baseUrl: string, path: string, init?: ApiFetchInit): Promise { +export async function apiFetch(baseUrl: string | undefined, path: string, init?: ApiFetchInit): Promise { const url = `${normalizeUrl(baseUrl)}${withProjectId(resolveApiPath(path), init?.projectId)}`; const headers = new Headers(init?.headers); diff --git a/apps/cli/src/utils/gates.ts b/apps/cli/src/utils/gates.ts index 6f914bc2..9d0de979 100644 --- a/apps/cli/src/utils/gates.ts +++ b/apps/cli/src/utils/gates.ts @@ -7,13 +7,12 @@ import { GATES, isGateId, isPromptType, - orderGates, PROMPT_TYPES, - validateGateConfigs, type GateConfig, type GateId, type PromptType, -} from "shared"; +} from "shared/types"; +import { orderGates, validateGateConfigs } from "shared/gates"; export { GATES }; export type { GateConfig, GateId, PromptType }; diff --git a/apps/cli/src/utils/shared.test.ts b/apps/cli/src/utils/shared.test.ts index ae357e5e..3be3fd6a 100644 --- a/apps/cli/src/utils/shared.test.ts +++ b/apps/cli/src/utils/shared.test.ts @@ -1,40 +1,31 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, it, expect } from "vitest"; -import { applyApiPortFallback } from "./shared.js"; +import { afterEach, describe, it, expect, vi } from "vitest"; +import { getDefaultApiUrl, normalizeUrl } from "./shared.js"; -describe("applyApiPortFallback", () => { - it("sets SCOPE_API_URL from SCOPE_API_PORT when SCOPE_API_URL is unset", () => { - const env: NodeJS.ProcessEnv = { SCOPE_API_PORT: "5108" }; - applyApiPortFallback(env); - expect(env.SCOPE_API_URL).toBe("http://localhost:5108"); - }); +afterEach(() => vi.unstubAllEnvs()); - it("does not override an already-set SCOPE_API_URL", () => { - const env: NodeJS.ProcessEnv = { - SCOPE_API_URL: "https://api.example.com", - SCOPE_API_PORT: "5108", - }; - applyApiPortFallback(env); - expect(env.SCOPE_API_URL).toBe("https://api.example.com"); +describe("getDefaultApiUrl", () => { + it("reads the explicitly configured URL", () => { + vi.stubEnv("SCOPE_API_URL", " https://api.example.com "); + expect(getDefaultApiUrl()).toBe("https://api.example.com"); }); - it("is a no-op when SCOPE_API_PORT is unset", () => { - const env: NodeJS.ProcessEnv = {}; - applyApiPortFallback(env); - expect(env.SCOPE_API_URL).toBeUndefined(); + it.each([undefined, "", " "])("has no fallback when SCOPE_API_URL is %j", (url) => { + vi.stubEnv("SCOPE_API_URL", url); + vi.stubEnv("SCOPE_DEFAULT_API_URL", "https://legacy.example.com"); + vi.stubEnv("SCOPE_API_PORT", "5108"); + expect(getDefaultApiUrl()).toBeUndefined(); }); +}); - it("trims whitespace around a numeric SCOPE_API_PORT", () => { - const env: NodeJS.ProcessEnv = { SCOPE_API_PORT: " 3200 " }; - applyApiPortFallback(env); - expect(env.SCOPE_API_URL).toBe("http://localhost:3200"); +describe("normalizeUrl", () => { + it.each([undefined, "", " "])("rejects missing URL %j with configuration guidance", (url) => { + expect(() => normalizeUrl(url)).toThrow("No API URL configured. Set SCOPE_API_URL or pass -u/--url"); }); - it("ignores non-numeric SCOPE_API_PORT values rather than producing an unreachable URL", () => { - const env: NodeJS.ProcessEnv = { SCOPE_API_PORT: "not-a-port" }; - applyApiPortFallback(env); - expect(env.SCOPE_API_URL).toBeUndefined(); + it("trims whitespace and trailing slashes from an explicit URL", () => { + expect(normalizeUrl(" https://api.example.com/// ")).toBe("https://api.example.com"); }); }); diff --git a/apps/cli/src/utils/shared.ts b/apps/cli/src/utils/shared.ts index 73b28859..9158a321 100644 --- a/apps/cli/src/utils/shared.ts +++ b/apps/cli/src/utils/shared.ts @@ -5,8 +5,14 @@ import { Command } from "commander"; import { dimTimestamp, label, value } from "./style.js"; import { generateOutputFormatsHelp } from "./helpFormatter.js"; -/** Strip trailing slashes from a URL to avoid double-slash issues when appending paths */ -export const normalizeUrl = (url: string): string => url.replace(/\/+$/, ''); +/** Require an explicitly configured API URL before constructing a request. */ +export function normalizeUrl(url: string | undefined): string { + const configured = url?.trim(); + if (!configured) { + throw new Error("No API URL configured. Set SCOPE_API_URL or pass -u/--url (MCP server create/update: --api-url)."); + } + return configured.replace(/\/+$/, ""); +} /** * Detect how the CLI was invoked and return the appropriate command prefix. @@ -33,27 +39,15 @@ export function printFollowUpCommands(id: string): void { console.log(` ${dimTimestamp('List all runs:')} ${cli} run list`); } -/** - * Default API URL. Computed lazily so that dotenv and applyApiPortFallback() - * have a chance to populate process.env before this is read. - * In dev mode this is localhost; the esbuild bundle replaces - * SCOPE_DEFAULT_API_URL with the production URL at build time. - */ -export function getDefaultApiUrl(): string { - return process.env.SCOPE_API_URL || process.env.SCOPE_DEFAULT_API_URL || "http://localhost:3100"; +/** Read explicit environment configuration after dotenv has loaded. */ +export function getDefaultApiUrl(): string | undefined { + return process.env.SCOPE_API_URL?.trim() || undefined; } -// For backward compatibility — used in help text generation at setup time -export const DEFAULT_API_URL: string = process.env.SCOPE_DEFAULT_API_URL || "http://localhost:3100"; - // Environment variable definitions surfaced in `--help` export const ENV_VARS = { SCOPE_API_URL: { - description: 'Default API base URL used by the -u, --url option of every command', - default: DEFAULT_API_URL, - }, - SCOPE_API_PORT: { - description: 'When SCOPE_API_URL is unset, derive it as http://localhost:$SCOPE_API_PORT (useful for local docker-compose setups)', + description: 'API base URL. Required for API operations unless -u/--url is supplied; there is no default.', }, SCOPE_MT_DOWNLOAD_OUTPUT_DIR: { description: 'Default download directory for `run get` / `run watch` when --download-output-dir is omitted', @@ -63,24 +57,6 @@ export const ENV_VARS = { }, } as const; -/** - * Derive the default `SCOPE_API_URL` from `SCOPE_API_PORT` when `SCOPE_API_URL` - * is not already set. Useful for local docker-compose setups where the API - * port is the only piece of configuration that varies. - * - * Mutates `env` in place when a port is present and the port string is purely - * numeric. Whitespace around `SCOPE_API_PORT` is tolerated; non-numeric values - * are ignored so a typo doesn't silently produce an unreachable URL. - * - * Idempotent: if `SCOPE_API_URL` is already defined, `env` is left untouched. - */ -export function applyApiPortFallback(env: NodeJS.ProcessEnv = process.env): void { - if (env.SCOPE_API_URL || !env.SCOPE_API_PORT) return; - const port = env.SCOPE_API_PORT.trim(); - if (!/^\d+$/.test(port)) return; - env.SCOPE_API_URL = `http://localhost:${port}`; -} - // Output format definitions with descriptions and categories export const OUTPUT_FORMATS = { table: { section: 'Human-readable formats', description: 'Formatted table with borders (default for lists)' }, diff --git a/apps/cli/src/utils/update-check.integration.test.ts b/apps/cli/src/utils/update-check.integration.test.ts index fb42036a..bab39e48 100644 --- a/apps/cli/src/utils/update-check.integration.test.ts +++ b/apps/cli/src/utils/update-check.integration.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, afterEach, afterAll } from "vitest"; import { createServer, type Server } from "node:http"; -import { mkdtempSync, rmSync } from "node:fs"; +import { copyFileSync, mkdtempSync, rmSync } from "node:fs"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { resolve, join } from "node:path"; @@ -45,9 +45,14 @@ function stopServers(): void { /** Run a command against the bundle with controlled env */ async function runBundle(args: string[], tempDir: string) { - return execFileAsync("node", [BUNDLE_PATH, ...args], { + const isolatedBundle = join(tempDir, "scope.mjs"); + copyFileSync(BUNDLE_PATH, isolatedBundle); + return execFileAsync(process.execPath, ["--no-global-search-paths", isolatedBundle, ...args], { + cwd: tempDir, env: { ...process.env, + NODE_PATH: "", + NODE_OPTIONS: "", SCOPE_NO_UPDATE_CHECK: "", SCOPE_RELEASES_URL: `http://127.0.0.1:${releasePort}`, SCOPE_API_URL: `http://127.0.0.1:${apiPort}`, @@ -107,9 +112,14 @@ describe("update-check (via bundle)", () => { await startServers(); const tempDir = mkdtempSync(join(tmpdir(), "scope-uc-")); try { - const { stderr } = await execFileAsync("node", [BUNDLE_PATH, "run", "list"], { + const isolatedBundle = join(tempDir, "scope.mjs"); + copyFileSync(BUNDLE_PATH, isolatedBundle); + const { stderr } = await execFileAsync(process.execPath, ["--no-global-search-paths", isolatedBundle, "run", "list"], { + cwd: tempDir, env: { ...process.env, + NODE_PATH: "", + NODE_OPTIONS: "", SCOPE_NO_UPDATE_CHECK: "1", SCOPE_RELEASES_URL: `http://127.0.0.1:${releasePort}`, SCOPE_API_URL: `http://127.0.0.1:${apiPort}`, diff --git a/docs/architecture/cli-distribution.md b/docs/architecture/cli-distribution.md index 25b0e98e..b2906b3e 100644 --- a/docs/architecture/cli-distribution.md +++ b/docs/architecture/cli-distribution.md @@ -60,9 +60,15 @@ of these entry points builds `shared` first (topologically for `pnpm -r`, explic | Define | Source | Purpose | |--------|--------|---------| | `process.env.SCOPE_CLI_VERSION` | `apps/cli/package.json` version | Reported by `--version` | -| `process.env.SCOPE_DEFAULT_API_URL` | `SCOPE_DEFAULT_API_URL` env var or `https://msscope.azurewebsites.net` | Default API URL in bundled builds | -In dev mode (`pnpm cli` via tsx), these defines are not applied — the CLI falls back to `http://localhost:3100`. +There is no build-time or runtime default API destination. Both the bundle and +source-mode CLI require `SCOPE_API_URL` or a command's `-u/--url` option +(`--api-url` for MCP server create/update); the command-line option takes +precedence. Missing or blank URLs fail before an API request with configuration +guidance. `SCOPE_DEFAULT_API_URL` is no longer injected or read, and +`SCOPE_API_PORT` no longer derives a localhost destination. Local development +must also configure `SCOPE_API_URL` explicitly. Help, version, and CLI updates +remain available without API configuration. ### esbuild plugins @@ -75,8 +81,32 @@ In dev mode (`pnpm cli` via tsx), these defines are not applied — the CLI fall - **ESM format** with a `createRequire` polyfill banner (CJS won't work due to Ink's top-level await) - **All dependencies bundled** — no `node_modules` needed at runtime +- **Pure shared runtime imports** — CLI gate constants and helpers come from + `shared/types` and `shared/gates`, not the top-level `shared` barrel. The barrel + also initializes server-side modules with dynamic Redis imports that esbuild + cannot bundle. Type-only imports from `shared` are safe because they are erased. - **Node.js >= 20 required** at runtime +### Standalone bundle validation + +Build first, then exercise the actual artifact: + +```bash +pnpm build:cli +node apps/cli/dist/scope.mjs --version +pnpm exec vitest run --config vitest.integration.config.ts \ + apps/cli/src/bundle.integration.test.ts \ + apps/cli/src/utils/update-check.integration.test.ts +``` + +Both suites copy the bundle outside the checkout and run it from that temporary +directory using `process.execPath`, empty `NODE_PATH`/`NODE_OPTIONS`, and +`--no-global-search-paths`. This prevents pnpm's Vitest launcher from making +workspace dependencies available to the child process and hiding missing bundled +modules. Coverage includes the extensionless installed `scope` executable, mock +API commands, missing-URL failures, explicit URL precedence, offline help/version, +and update checks; no release is published by these tests. + ## Versioning The release workflow sorts valid `cli/v*` tags in `microsoft/scope` semantically @@ -212,7 +242,7 @@ unique and need no project. See | Aspect | Dev (`pnpm cli`) | Bundled (`scope`) | |--------|-------------------|-------------------| | Runner | tsx (TypeScript direct) | Node.js (single .mjs) | -| API default | `http://localhost:3100` | `https://msscope.azurewebsites.net` | +| API default | None; configure `SCOPE_API_URL` or `-u` | None; configure `SCOPE_API_URL` or `-u` | | Version | `0.1.0-dev` | Embedded package version (`0.0.0-dev` locally) | | Command name | `pnpm cli` | `scope` | | Update check | Disabled | Enabled | diff --git a/packages/shared/package.json b/packages/shared/package.json index 934974c7..4e2a7e24 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -10,6 +10,16 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./types": { + "source": "./src/types/index.ts", + "types": "./dist/types/index.d.ts", + "import": "./dist/types/index.js" + }, + "./gates": { + "source": "./src/gates/index.ts", + "types": "./dist/gates/index.d.ts", + "import": "./dist/gates/index.js" + }, "./criteria-store": { "source": "./src/criteria/criteria-store.ts", "types": "./dist/criteria/criteria-store.d.ts",