Skip to content
Merged
21 changes: 18 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ on:
branches: [main]
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
test:
name: test
runs-on: ubuntu-latest
name: test (${{ matrix.os }})
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Check out source
uses: actions/checkout@v4
Expand All @@ -25,10 +29,21 @@ jobs:
- name: Run tests
run: npm test

test-gate:
name: test
if: ${{ always() }}
needs: test
runs-on: ubuntu-latest
steps:
- name: Require successful test matrix
env:
MATRIX_RESULT: ${{ needs.test.result }}
run: test "${MATRIX_RESULT}" = "success"

release:
name: release
if: github.event_name == 'push'
needs: test
needs: test-gate
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ Before installing, review the release source and the [skill instructions][skill]
| A writable local Git working tree | The executor validates the local target before planning or applying. |
| An `origin` remote matching the configured `owner/repository` | Prevents applying a reviewed manifest to a different repository. |
| GitHub CLI (`gh`) with authenticated, configured scopes | Used to discover GitHub state and apply GitHub resource changes. |
| Linux descriptor-relative filesystem support for managed file or template writes | Those local writes fail closed when the required safe-write support is unavailable. |

| Safe local file writes | Managed file and template writes use Linux descriptor traversal on Linux. macOS and Windows enforce path confinement, symlink rejection, atomic replacement, and permission preservation. Full race immunity against parent swaps requires Linux descriptor support. |

Projects v2 discovery, GraphQL, and mutations run only when the manifest includes `project`. Its required scopes are also manifest-driven.

Expand Down
1 change: 1 addition & 0 deletions skills/github-repository-bootstrap/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Use for repeatable GitHub repository setup. Treat `assets/config.schema.json` as
- Keep the fixed template set only: `bug_report`, `feature_request`, and the pull-request template. Arbitrary managed template files are out of scope.
- Run `plan` before mutation. Apply only after explicit authorization with the exact SHA-256 value from that reviewed plan; never reuse it after any config, target, discovery, or plan change.
- Require `gh`, authentication, applicable scopes, target access, and valid configuration before mutation. Run Projects v2 discovery, GraphQL, and mutations only when `project` is configured.
- Local writes use descriptor-relative traversal on Linux for race-free parent confinement. macOS and Windows provide a narrower guarantee: root confinement, symlink rejection, atomic sibling replacement, and permission preservation. Full TOCTOU race immunity requires Linux descriptor support. There is no bypass flag.

## Execution Steps

Expand Down
60 changes: 58 additions & 2 deletions skills/github-repository-bootstrap/scripts/bootstrap.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,68 @@ function parseArgs(argv) {
return result;
}

function run(command, args, options = {}) {
function rejectBatchScript(candidate) {
if (/\.(cmd|bat)$/i.test(candidate)) {
throw new Error(
`Unsupported batch script wrapper: ${candidate}. Executing .cmd or .bat files crosses a command shell boundary; provide a direct executable binary (such as gh.exe) instead.`,
);
}
}

export function resolveCommand(command) {
if (path.isAbsolute(command) || command.includes("/") || command.includes("\\")) {
if (fs.existsSync(command)) {
rejectBatchScript(command);
return { cmd: command, args: [] };
}
if (fs.existsSync(command + ".exe")) {
return { cmd: command + ".exe", args: [] };
}
for (const ext of [".cmd", ".bat"]) {
if (fs.existsSync(command + ext)) rejectBatchScript(command + ext);
}
return { cmd: command, args: [] };
}

if (process.platform === "win32") {
const pathDirs = (process.env.PATH || "").split(path.delimiter);
const extensions = (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM")
.split(";")
.map((ext) => ext.toLowerCase());

let batchMatch = null;
for (const dir of pathDirs) {
if (!dir) continue;
for (const ext of ["", ...extensions]) {
const candidate = path.join(dir, command + ext);
try {
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
if (/\.(cmd|bat)$/i.test(candidate)) {
if (!batchMatch) batchMatch = candidate;
continue;
}
return { cmd: candidate, args: [] };
}
} catch {
// Ignore unreadable PATH entries and keep searching.
}
}
}
if (batchMatch) rejectBatchScript(batchMatch);
}

return { cmd: command, args: [] };
}

export function run(command, args, options = {}) {
const resolved = resolveCommand(command);
const commandArgs = resolved.args ? [...resolved.args, ...args] : args;
try {
return execFileSync(command, args, {
return execFileSync(resolved.cmd, commandArgs, {
encoding: "utf8",
input: options.input,
stdio: ["pipe", "pipe", "pipe"],
shell: false,
});
} catch (error) {
const detail = String(error.stderr || error.message)
Expand Down
Loading
Loading