diff --git a/CHANGELOG.md b/CHANGELOG.md index 6feaeb1..bd00f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project are documented in this file. +## [0.0.31] - 2026-09-13 + +### Added + +- Added a least-privilege GitHub App authentication pattern for workflows that resolve private sibling repositories, including short-lived read-only tokens, process-scoped Git configuration, exact repository selection, and fork pull-request and self-hosted-runner security boundaries. + ## [0.0.30] - 2026-09-12 ### Changed diff --git a/Guidelines/CICD.md b/Guidelines/CICD.md index 190739d..5138aca 100644 --- a/Guidelines/CICD.md +++ b/Guidelines/CICD.md @@ -20,6 +20,39 @@ Use Swift for new repository-owned executable scripts in Swift-focused applicati Fall back to Python or a POSIX shell script only when the required behavior cannot be implemented with the repository's supported Swift toolchain and Foundation APIs. Document the exception in durable repository documentation in the same change, including the missing Swift capability, exact script and task scope, runtime and dependency requirements, security and maintenance impact, validation method, and condition for revisiting or removing the exception. Keep the fallback narrow; an existing non-Swift script does not authorize another one. The central `Scripts/swift_format.sh` command wrapper is the retained documented exception for invoking Xcode's `swift-format` modes. +## Private repository dependencies + +The workflow repository's `GITHUB_TOKEN` does not grant access to private dependencies in sibling repositories. When Swift Package Manager or another build tool must clone private ThatFactory repositories, use a GitHub App installed on every required dependency repository. The app does not need access to the workflow repository unless that repository is also an intended token target. Grant the app only read access to repository contents, mint a short-lived installation token with `actions/create-github-app-token`, and list the exact dependency repositories in the action's `repositories` input. Do not use a personal access token, a long-lived machine credential, or an organization-wide token when the GitHub App can provide the required scope. + +Expose the installation token only to steps that resolve or build the private dependencies. Supply HTTPS authentication through Git's process-level `GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_0`, and `GIT_CONFIG_VALUE_0` environment variables so the credential is not persisted in repository or global Git configuration. Keep the existing dependency URLs as `https://github.com//` URLs. For example: + +```yaml +- name: Create private dependency token + id: private-dependencies + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.PRIVATE_DEPENDENCIES_APP_CLIENT_ID }} + private-key: ${{ secrets.PRIVATE_DEPENDENCIES_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: | + first-private-package + second-private-package + permission-contents: read + +- name: Test + env: + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: url.https://x-access-token:${{ steps.private-dependencies.outputs.token }}@github.com/.insteadOf + GIT_CONFIG_VALUE_0: https://github.com/ + run: swift test +``` + +The GitHub App's installation and repository selection are part of the security boundary. Consumer documentation must name the app variable and secret, list the private repositories the workflow requires, record the required `Contents: read` permission, and identify the jobs or steps that receive the token. Keep pull-request and protected-branch workflows consistent unless a documented trust boundary requires otherwise. Because `GIT_CONFIG_*` values are ordinary inherited environment variables, treat the credential-bearing resolve or build step and its complete subprocess tree as privileged. Tests, build scripts, SwiftPM plugins, and other code executed beneath that step must be trusted to receive read access to every repository in the token scope. + +Repository secrets are unavailable to workflows triggered by pull requests from forks. A repository that accepts fork-originated or otherwise untrusted pull requests must keep a secretless validation path or deliberately skip private-dependency jobs with an explicit, documented condition. Untrusted code must not execute on a persistent self-hosted runner that is later reused for credential-bearing work. Use an isolated disposable or ephemeral self-hosted runner, an appropriate GitHub-hosted runner where possible, or a separate runner pool or host that never subsequently receives secrets; otherwise skip the untrusted validation. + +This trust rule is event-independent. Do not combine credentials with code that is not trusted at that privilege level under `pull_request`, `pull_request_target`, `issue_comment`, `workflow_run`, or another trigger. On self-hosted runners, do not expose the token to unrelated steps, caches, artifacts, logs, or persistent configuration; retain the action's default post-job token revocation. + ## `ci-pr.yml` Projects using GitHub Actions should keep pull-request validation in `.github/workflows/ci-pr.yml`, triggered by `pull_request` events for `opened`, `synchronize`, and `reopened`. diff --git a/README.md b/README.md index 821a309..0c684f2 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ From the consumer repository root, install a tagged release: git subtree add \ --prefix=AgentGuidelines \ https://github.com/thatfactory/agent-guidelines.git \ - 0.0.30 \ + 0.0.31 \ --squash ``` @@ -147,7 +147,7 @@ Review the target release's changelog, then pull it deliberately: git subtree pull \ --prefix=AgentGuidelines \ https://github.com/thatfactory/agent-guidelines.git \ - 0.0.30 \ + 0.0.31 \ --squash ``` diff --git a/Scripts/validate_guidelines.swift b/Scripts/validate_guidelines.swift index 3139e94..655eadd 100755 --- a/Scripts/validate_guidelines.swift +++ b/Scripts/validate_guidelines.swift @@ -579,11 +579,21 @@ func validateExternalDependencyPolicy(_ errors: inout [String]) { if let contents = readText(cicdGuideline, errors: &errors) { let required = [ "## Tooling and automation": "CI/CD tooling policy section", + "## Private repository dependencies": "private repository dependency authentication section", "Fastlane is forbidden": "forbidden delivery tooling", "xcode-cloud-mcp": "first-party Xcode Cloud tooling", "app-store-connect-mcp": "first-party App Store tooling", "required behavior cannot be implemented": "non-Swift capability-gap threshold", "missing Swift capability": "documented non-Swift exception", + "actions/create-github-app-token@v3": "short-lived GitHub App token workflow", + "client-id:": "current GitHub App client identifier input", + "permission-contents: read": "read-only private dependency permission", + "GIT_CONFIG_KEY_0": "process-level Git authentication", + "GIT_CONFIG_VALUE_0: https://github.com/": "GitHub HTTPS rewrite source", + "complete subprocess tree as privileged": "credential-bearing subprocess trust boundary", + "isolated disposable or ephemeral self-hosted runner": "untrusted-code runner isolation", + "This trust rule is event-independent": "event-independent credential boundary", + "pull_request_target": "untrusted pull-request credential boundary", ] for (value, description) in required where !contents.contains(value) { errors.append("Guidelines/CICD.md: missing \(description): '\(value)'") diff --git a/Tests/run_tests.swift b/Tests/run_tests.swift index 516c7c8..3d61c70 100755 --- a/Tests/run_tests.swift +++ b/Tests/run_tests.swift @@ -146,6 +146,27 @@ let tests: [(String, () throws -> Void)] = [ } } ), + ( + "repository validator rejects private dependency authentication drift", + { + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let guideline = fixture.appendingPathComponent("Guidelines/CICD.md") + var contents = try String(contentsOf: guideline, encoding: .utf8) + contents = contents.replacingOccurrences( + of: "isolated disposable or ephemeral self-hosted runner", + with: "self-hosted runner") + try write(contents, to: guideline) + let result = try run([fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path]) + try require(!result.succeeded, "private dependency authentication drift unexpectedly passed") + try require( + result.output.contains("missing untrusted-code runner isolation"), + result.output + ) + } + } + ), ( "repository validator rejects gitignore template drift", { diff --git a/VERSION b/VERSION index f092e2b..d788d43 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.30 +0.0.31