diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 685e7a5d4..1f0ad7e2d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -48,6 +48,32 @@ jobs: } } + shell-script-permissions: + name: Shell Script Permissions + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: scripts/package.json + + - name: Install scripts dependencies + working-directory: ./scripts + run: npm ci --ignore-scripts + + - name: Validate shebang shell scripts are executable + run: npm run check:shell-scripts + eslint: name: ESLint runs-on: ubuntu-latest diff --git a/package.json b/package.json index 3537930d9..ce63a4cdb 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "GitHub Copilot plugin for Azure", "scripts": { "build": "gulp", + "check:shell-scripts": "cd scripts && npm run checkShellScriptPermissions", "tokens": "cd scripts && npm run tokens --", "verify-local": "echo 'Removed: use npm run build + copilot --plugin-dir ./output instead' && exit 1", "test": "cd scripts && npm test", diff --git a/scripts/package.json b/scripts/package.json index dfbfe581a..eadb39950 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "tsc", "checkCopilotCliCharBudget": "node --import tsx src/copilot-cli-char-budget.ts", + "checkShellScriptPermissions": "node --import tsx src/check-shell-script-permissions.ts", "checkPluginVersionPr": "node --import tsx src/check-plugin-version-pr.ts", "verifyBuildOutputVersions": "node --import tsx src/verify-build-output-versions.ts", "tokens": "node --import tsx src/tokens/cli.ts", diff --git a/scripts/src/check-shell-script-permissions.ts b/scripts/src/check-shell-script-permissions.ts new file mode 100644 index 000000000..a07673148 --- /dev/null +++ b/scripts/src/check-shell-script-permissions.ts @@ -0,0 +1,49 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** Runs a Git command and returns its text output. */ +function runGit(args: string[]): string { + return execFileSync("git", args, { encoding: "utf8" }); +} + +/** Verifies tracked shebang-bearing shell scripts have executable Git modes. */ +function checkShellScriptPermissions(): boolean { + const repositoryRoot = runGit(["rev-parse", "--show-toplevel"]).trim(); + const entries = runGit(["ls-files", "--stage", "-z", "--", ":(glob)**/*.sh"]) + .split("\0") + .filter(Boolean); + const invalidFiles: string[] = []; + + for (const entry of entries) { + const separatorIndex = entry.indexOf("\t"); + if (separatorIndex < 0) { + throw new Error(`Unexpected git ls-files output: ${entry}`); + } + + const metadata = entry.slice(0, separatorIndex).split(" "); + const file = entry.slice(separatorIndex + 1); + const firstLine = readFileSync(join(repositoryRoot, file), "utf8").split(/\r?\n/, 1)[0]; + + if (firstLine.startsWith("#!") && metadata[0] !== "100755") { + invalidFiles.push(file); + } + } + + if (invalidFiles.length === 0) { + console.log("All tracked shebang-bearing .sh files are executable."); + return true; + } + + console.error("The following tracked shebang-bearing .sh files are not executable:"); + for (const file of invalidFiles) { + console.error(` ${file}`); + } + console.error("Run: git update-index --chmod=+x "); + return false; +} + +const valid = checkShellScriptPermissions(); +process.exitCode = valid ? 0 : 1; + +export { checkShellScriptPermissions };