diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index c36170282..cc2ded305 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 b549af11e..000000000 --- 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 256fa0f33..ceeac9b9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,10 @@ on: - "vitest.config.ts" - "vitest.integration.config.ts" - ".github/workflows/ci.yml" + - ".github/workflows/publish-cli.yml" + - ".dockerignore" + - "install-cli.sh" + - "website/install-cli.sh" pull_request: branches: [main] paths: @@ -29,15 +33,13 @@ on: - "vitest.config.ts" - "vitest.integration.config.ts" - ".github/workflows/ci.yml" + - ".github/workflows/publish-cli.yml" + - ".dockerignore" + - "install-cli.sh" + - "website/install-cli.sh" workflow_dispatch: - inputs: - images: - description: 'Comma-separated list of images to build (or "all"). Leave empty to run CI only.' - required: false - default: "" permissions: - id-token: write contents: read pull-requests: write issues: write @@ -154,8 +156,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 @@ -214,36 +216,25 @@ jobs: name: Integration Tests (${{ matrix.worker.name }}) needs: [detect-changes] if: >- - github.repository == 'growth-ecosystems/scope-core' && + 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 +250,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 +312,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 +330,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 +365,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 @@ -404,13 +386,18 @@ 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.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 +426,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 }} @@ -749,12 +739,35 @@ 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 outputs: + ci: ${{ steps.changes.outputs.ci }} api: ${{ steps.changes.outputs.api }} judge: ${{ steps.changes.outputs.judge }} portal: ${{ steps.changes.outputs.portal }} @@ -782,6 +795,9 @@ jobs: with: base: ${{ github.event.before }} filters: | + ci: + - '.github/workflows/ci.yml' + - 'scripts/ci-workflow.test.ts' shared: - 'packages/shared/**' - 'package.json' @@ -821,8 +837,22 @@ 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' - 'packages/test-utils/**' 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' + - 'website/install-cli.sh' + - '.dockerignore' + - 'pnpm-workspace.yaml' - 'apps/**' - '!apps/gateway/**' - 'packages/**' @@ -838,370 +868,13 @@ 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 == 'growth-ecosystems/scope-core' && - (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 - 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}" ;; - 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 - 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 == 'growth-ecosystems/scope-core' && - (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 - 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 ${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}" - - 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 # --------------------------------------------------------------------------- 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: @@ -1210,6 +883,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" ] && \ @@ -1224,6 +898,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/daily-repo-status.lock.yml b/.github/workflows/daily-repo-status.lock.yml deleted file mode 100644 index c83fdb957..000000000 --- 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 4e219ef67..000000000 --- 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 index dd709a162..290fe35b1 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -4,133 +4,111 @@ on: workflow_dispatch: inputs: bump: - description: "Version bump type" + description: Version bump type required: true - default: "minor" + default: minor type: choice - options: - - patch - - minor - - major + options: [patch, minor, major] permissions: - contents: write + contents: read -env: - SCOPE_DOC_REPO: growth-ecosystems/scope-doc +concurrency: + group: publish-cli + cancel-in-progress: false jobs: test: - name: Build & Test CLI + 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: - - name: Checkout repository - uses: actions/checkout@v4 - + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false - name: Install pnpm run: | corepack enable corepack prepare pnpm@10.29.1 --activate - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: - node-version: 22 - cache: "pnpm" - + 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 + - name: Resolve next CLI version id: version working-directory: apps/cli + env: + BUMP: ${{ inputs.bump }} run: | - NEW_VERSION=$(pnpm version ${{ inputs.bump }} --no-git-tag-version | tr -d 'v') - echo "version=${NEW_VERSION}" >> "$GITHUB_OUTPUT" - + 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 - working-directory: apps/cli + run: pnpm build:cli + - name: Test release bundle 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 + 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-bundle + name: cli-release-bundle path: apps/cli/dist/scope.mjs - - outputs: - version: ${{ steps.version.outputs.version }} + if-no-files-found: error publish: - name: Publish Release + 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: - - 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 + - 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 + name: cli-release-bundle + path: bundle + - name: Create release in this repository env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ github.token }} VERSION: ${{ needs.test.outputs.version }} + COMMIT: ${{ github.sha }} run: | gh release create "cli/v${VERSION}" \ - --repo "$SCOPE_DOC_REPO" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$COMMIT" \ --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 + --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 8dddf3252..79d8280df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,6 +119,47 @@ 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, 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 +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 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. 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 remain necessary for live-auth tests; passing public +tool checks does not validate live-model access. + +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 ```bash @@ -295,7 +336,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` | | Pull Request Labeler | Area, topic, language, and type labels from `.github/labeler.yml` | @@ -326,7 +366,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/ENV_VARIABLES.md b/ENV_VARIABLES.md index 9ce760b60..950574743 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 01fd990da..1da7085cf 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" @@ -20,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 @@ -32,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 @@ -90,10 +99,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: @@ -106,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 75a666fa8..4b8a25f0e 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/package.json b/apps/cli/package.json index b7f838612..3a73a038f 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/bundle.integration.test.ts b/apps/cli/src/bundle.integration.test.ts index d295353ce..11d8e0714 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 7406fe77b..e59d0d79e 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 51aba1f5d..176d5896c 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/commands/update.test.ts b/apps/cli/src/commands/update.test.ts index 004dd6c63..25aec639a 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 2448a952f..0cbed9712 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/index.ts b/apps/cli/src/index.ts index 633539095..4089536bb 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 cf28cbb34..1715dff67 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 8f619ea31..7793f2922 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 e48f2f073..79a26f552 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 6f914bc26..9d0de979f 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 ae357e5e0..3be3fd6a4 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 73b288591..9158a321d 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 fb42036aa..bab39e487 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/apps/cli/src/utils/update-check.test.ts b/apps/cli/src/utils/update-check.test.ts new file mode 100644 index 000000000..2c891c140 --- /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 d0c9d7e93..b7f90e59b 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 441af47d6..ab16cd235 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 9831e1edc..f2e1db1a5 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 df359cdfa..eadb11c1d 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. +# scripts/build-windows-worker.ps1 builds base, deps and worker images locally. FROM ${DEPS_IMAGE} AS base # --- Builder stage: compile TypeScript --- @@ -19,7 +19,7 @@ COPY packages/test-utils/package.json ./packages/test-utils/ 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/ @@ -32,7 +32,11 @@ 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 test-utils 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 test-utils 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 @@ -52,7 +56,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 b46566de1..b2906b3ed 100644 --- a/docs/architecture/cli-distribution.md +++ b/docs/architecture/cli-distribution.md @@ -1,15 +1,19 @@ # CLI Distribution -How the Scope CLI is bundled, distributed, and updated as a standalone tool. +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/), 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 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[scope-core
apps/cli/] -->|publish-cli.yml| B[GitHub Actions] - B -->|gh release create| C[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] ``` @@ -36,9 +40,9 @@ 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 -**CLI Bundle Integration Tests** job) — the CLI is now typechecked automatically wherever it is +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** 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. @@ -56,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 @@ -71,59 +81,103 @@ 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 **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 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`. -- Only CI-built releases 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 -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` +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. -### Required secrets/variables - -| 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 | +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. ## 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. + +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. -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 @@ -188,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` | -| Version | `0.1.0-dev` | Actual semver from CI bump | +| 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/install-cli.sh b/install-cli.sh new file mode 100755 index 000000000..40e209a1b --- /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/packages/shared/package.json b/packages/shared/package.json index 934974c7d..4e2a7e243 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", diff --git a/scripts/build-windows-worker.ps1 b/scripts/build-windows-worker.ps1 new file mode 100644 index 000000000..b8fd9de83 --- /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 000000000..4fa48681b --- /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 new file mode 100644 index 000000000..81afcfe24 --- /dev/null +++ b/scripts/ci-workflow.test.ts @@ -0,0 +1,270 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawnSync } from "node:child_process"; +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"; +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 { + id?: string; + name?: string; + uses?: string; + run?: string; + if?: string; + env?: Record; + with?: Record; + shell?: string; +} + +interface Job { + if?: 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 }[] } }; +} + +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 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"); + 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(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 remaining inline shell scripts", () => { + for (const job of Object.values(jobs)) { + 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(""); + expect(result.status, command.name).toBe(0); + } + } + }); + + 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.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", "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); + } + }); + + it("does not run fork code through pull_request_target", () => { + 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("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"); + 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 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"]) { + 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"]) { + 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/); + } + }); + + 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 000000000..7636385c8 --- /dev/null +++ b/scripts/install-cli.test.ts @@ -0,0 +1,131 @@ +// 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://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') { + 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.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" }, + { 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 }); + writeFileSync(join(directory, "gh"), '#!/bin/sh\necho "Unexpected GitHub authentication dependency" >&2\nexit 1\n', { mode: 0o755 }); + try { + 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([ + { 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"]); + 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 f77bba01f..c82223ff7 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 36d397f0e..bab47c2ad 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 d2df9aefe..cf2a0f961 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 5c7f164a2..2d05af5a7 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