From 235de6ab261ff2b5718ddbb494f05b1c6e413c99 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr <59268465+jmcte@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:57:47 +0100 Subject: [PATCH] chore: refresh Bootstrap reconciliation Regenerate the repository reconciliation from the merged Bootstrap control plane and declare the supported CodeQL languages. Signed-off-by: John McChesney TenEyck Jr <59268465+jmcte@users.noreply.github.com> --- .bootstrap/managed-files.json | 116 ++++++++++ .githooks/pre-commit | 3 +- .github/PULL_REQUEST_TEMPLATE.md | 5 + .github/workflows/claude.yml | 60 ++--- .github/workflows/extended-validation.yml | 40 ++-- .github/workflows/pr-fast-ci.yml | 268 +++++++++++++++------- AGENTS.md | 3 +- SECURITY.md | 9 +- docs/bootstrap/issue-hygiene.md | 31 +++ docs/bootstrap/onboarding.md | 27 ++- docs/bootstrap/security.md | 22 ++ project.bootstrap.yaml | 5 + scripts/check-detect-secrets.sh | 138 +++++------ scripts/ci/check-action-pins.sh | 35 +++ scripts/ci/check-pr-governance.sh | 124 ++++++++++ scripts/ci/report-issue-hygiene.mjs | 252 ++++++++++++++++++++ scripts/ci/run-extended-validation.sh | 68 +----- scripts/ci/run-fast-checks.sh | 57 +---- 18 files changed, 920 insertions(+), 343 deletions(-) create mode 100644 .bootstrap/managed-files.json create mode 100644 docs/bootstrap/issue-hygiene.md create mode 100644 docs/bootstrap/security.md create mode 100755 scripts/ci/check-action-pins.sh create mode 100755 scripts/ci/check-pr-governance.sh create mode 100755 scripts/ci/report-issue-hygiene.mjs diff --git a/.bootstrap/managed-files.json b/.bootstrap/managed-files.json new file mode 100644 index 0000000..835bbc0 --- /dev/null +++ b/.bootstrap/managed-files.json @@ -0,0 +1,116 @@ +{ + "schemaVersion": 1, + "owner": "bootstrap", + "templateVersion": "2026.03.28.2", + "regenerationCommand": "bootstrap apply repo --manifest ./project.bootstrap.yaml", + "managedFiles": { + ".devcontainer/devcontainer.json": { + "sha256": "c8cd91bc1d2df220508bd84051723d480fe16d7bf8d93a8c3fa0beac403083ad", + "source": "bootstrap" + }, + ".githooks/pre-commit": { + "sha256": "992bc9b04d62f84df70ba1e42d7159a1d976190eae41597810aaafaa30252a4d", + "source": "bootstrap" + }, + ".github/PULL_REQUEST_TEMPLATE.md": { + "sha256": "b95b973bf0e6f5cdf77ad9d84e2f623525e493beed185d7d7b3093a4f50ec59d", + "source": "bootstrap" + }, + ".github/workflows/claude.yml": { + "sha256": "8334876d6611aa25ec784e1e4560d8de3fa71efda129c1478f1d38e721d37d23", + "source": "bootstrap" + }, + ".github/workflows/extended-validation.yml": { + "sha256": "119a7b0a4a5382c9ffc0226b1100660bf76bba42e9fb89020650272269c06058", + "source": "bootstrap" + }, + ".github/workflows/pr-fast-ci.yml": { + "sha256": "91b158e63a648aad0639e2f18b3d97ff3798a2f490de477080551b3366965e69", + "source": "bootstrap" + }, + "AGENTS.md": { + "sha256": "8acfcb8fe4d902e7892abdee9db7073c4f5fe33ac4733813c83a83434885a997", + "source": "bootstrap" + }, + "CLAUDE.md": { + "sha256": "fa083f9f4b9b5a42c3179c0320070314ca7ae120dcf593b85677196e4914cdfe", + "source": "bootstrap" + }, + "CODEOWNERS": { + "sha256": "d5b88668ece5258a10e66ab476d2135250623474ff24d14748d071c5076559b0", + "source": "bootstrap" + }, + "docs/bootstrap/claude-environment.md": { + "sha256": "707dac647e02b7811f0ad99c367fbe9fc946aad3b0dd85366162eb10f39d2bb8", + "source": "bootstrap" + }, + "docs/bootstrap/codex-cloud-environment.md": { + "sha256": "d523693384512cd1b07a2e3467afba4ad45c6a41bc00ae69c01601f6af85f10e", + "source": "bootstrap" + }, + "docs/bootstrap/issue-hygiene.md": { + "sha256": "039344533cc90b966fd7ef6501bf6cfb956b5edc61126ad9c14aad545c315580", + "source": "bootstrap" + }, + "docs/bootstrap/next-steps.md": { + "sha256": "4e471faef1858d9f2c36328f10621dccd6b01fdc2fd33bd6547d494518b469b9", + "source": "bootstrap" + }, + "docs/bootstrap/onboarding.md": { + "sha256": "a223890f60a3d38cd68d320980f4e80c529a5c271053cb24639c68839d2dc92b", + "source": "bootstrap" + }, + "docs/bootstrap/security.md": { + "sha256": "d8deb9aa1b5e91177145b3bdf8b7e329abe3da574c6bf7f92e535f6e28b331e8", + "source": "bootstrap" + }, + "project.bootstrap.yaml": { + "sha256": "36db2b94523ad7f5925a97fe78840028061eab0be9af28e39492c186d5735258", + "source": "bootstrap" + }, + "scripts/check-detect-secrets.sh": { + "sha256": "7f94837cceed656f03390b3af615d260558ae71ed6e88c1580239b90d5115fa8", + "source": "bootstrap" + }, + "scripts/ci/check-action-pins.sh": { + "sha256": "4aaf63706ea4ba1754077fb449ffca310d971b9879ec999d26a3f942122788f8", + "source": "bootstrap" + }, + "scripts/ci/check-pr-governance.sh": { + "sha256": "296a576f96a19af3785a31a472640bf5e342113f64a366f7ca3292883c46f17c", + "source": "bootstrap" + }, + "scripts/ci/report-issue-hygiene.mjs": { + "sha256": "53bc2252f82e388ec4ba182cab500b78b3f429c304501a55c205ad3f0e534087", + "source": "bootstrap" + }, + "scripts/ci/run-extended-validation.sh": { + "sha256": "f2adbf95dde8933d554389d596ca874e48b81c7b58b9da2e64011042bb75d3a0", + "source": "bootstrap" + }, + "scripts/ci/run-fast-checks.sh": { + "sha256": "c7086acffa784b4249f0bb004a80f9ec93f6bee4004b06d897e702ff613892f3", + "source": "bootstrap" + }, + "scripts/claude-cloud/setup.sh": { + "sha256": "77a9e57f3dde3cbdbaa2b7385667392463236d79a1cf84b355f1944e08a72eaf", + "source": "bootstrap" + }, + "scripts/claude/setup-devcontainer.sh": { + "sha256": "4ad6cbff01875e6d1cb58e32460d10ca941defa0e02482dd20de65f15ba55211", + "source": "bootstrap" + }, + "scripts/codex-cloud/maintenance.sh": { + "sha256": "8a4f5e11b133539c46dd9d45e74d49b19e0fb092e3620f2af8552e07cf94dabc", + "source": "bootstrap" + }, + "scripts/codex-cloud/setup.sh": { + "sha256": "debc23607cccb92ed096941adc7da777ff794ec8c97d4efa842e592b33c29d10", + "source": "bootstrap" + }, + "SECURITY.md": { + "sha256": "74daa1c5bb75938c109739135fa6c15d948928936b37830e378ad6fc92bc414e", + "source": "bootstrap" + } + } +} diff --git a/.githooks/pre-commit b/.githooks/pre-commit index e79b100..ca4f60f 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -12,7 +12,8 @@ while IFS= read -r -d '' staged_file; do staged_files+=("$staged_file") done < <(git diff --cached --name-only --diff-filter=ACMR -z) -for f in "${staged_files[@]}"; do +for f in "${staged_files[@]:-}"; do + [[ -n "$f" ]] || continue case "$f" in *.env|.env.*) if [[ "$f" != *.example ]]; then diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e2316fc..4f18f69 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,6 +9,8 @@ Refs # + ## Merge Automation diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 594bfbb..a15ec96 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -4,9 +4,9 @@ on: workflow_dispatch: inputs: prompt: - description: 'Task for Claude to run in this repository' - required: true - default: 'Review the current branch changes for bugs, CI regressions, and missing tests.' + description: Optional manual task prompt + required: false + type: string issue_comment: types: [created] pull_request_review_comment: @@ -20,61 +20,27 @@ concurrency: permissions: contents: read - pull-requests: read + pull-requests: write + issues: write jobs: claude: if: | github.event_name == 'workflow_dispatch' || - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && - contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association)) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && - contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association)) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && - contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.review.author_association)) - # Claude has write permissions and reads ANTHROPIC_API_KEY, so keep it on a - # trusted private runner instead of the public shell-only fleet. - runs-on: ['self-hosted', 'private', 'macOS', 'ARM64', 'xcode'] + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) + runs-on: ubuntu-latest timeout-minutes: 30 - permissions: - contents: write - pull-requests: write - issues: write - id-token: write - actions: read steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Require Claude auth - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - run: | - if [[ -z "${ANTHROPIC_API_KEY}" ]]; then - echo "Missing repository secret ANTHROPIC_API_KEY. Run /install-github-app in Claude Code or add the secret before using this workflow." >&2 - exit 1 - fi - + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Run Claude Code - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - track_progress: true - use_sticky_comment: true - additional_permissions: "actions: read" prompt: | REPO: ${{ github.repository }} DEFAULT BRANCH: main - - Use CLAUDE.md and docs/bootstrap/onboarding.md as repo policy context. - Keep required PR status checks aligned with CI Gate. - Preserve the split fast and extended validation model. - Shell-safe jobs must use `[self-hosted, linux, shell-only, public]`. - Secret-bearing automation must stay on a trusted private runner. - Docker, service-container, browser, and `container:` jobs require a dedicated self-hosted pool with matching capability labels. - Prefer the smallest safe change and add tests for behavior changes. - + REQUIRED CHECKS: CI Gate + Use CLAUDE.md, AGENTS.md, and docs/bootstrap/onboarding.md as policy context. MANUAL TASK: ${{ github.event.inputs.prompt }} - If this is not a manual run, ignore the MANUAL TASK line and respond to the current `@claude` request instead. diff --git a/.github/workflows/extended-validation.yml b/.github/workflows/extended-validation.yml index ff43ddb..df8873a 100644 --- a/.github/workflows/extended-validation.yml +++ b/.github/workflows/extended-validation.yml @@ -31,7 +31,7 @@ jobs: ci: ${{ steps.preset.outputs.ci || steps.filter.outputs.ci || 'false' }} extended: ${{ steps.preset.outputs.extended || steps.filter.outputs.extended || 'false' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 if: github.event_name == 'push' with: fetch-depth: 0 @@ -40,11 +40,13 @@ jobs: id: preset if: github.event_name != 'push' run: | - echo "app=true" >> "$GITHUB_OUTPUT" - echo "ci=true" >> "$GITHUB_OUTPUT" - echo "extended=true" >> "$GITHUB_OUTPUT" + cat >>"$GITHUB_OUTPUT" <<'EOF' + app=true + ci=true + extended=true + EOF - - uses: dorny/paths-filter@v4 + - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4 id: filter if: github.event_name == 'push' with: @@ -52,8 +54,8 @@ jobs: app: - 'project.bootstrap.yaml' - 'AGENTS.md' - - 'CLAUDE.md' - - '.devcontainer/**' + - 'CONTRIBUTING.md' + - '.github/PULL_REQUEST_TEMPLATE.md' - '.githooks/**' - '.github/workflows/**' - 'scripts/**' @@ -63,8 +65,8 @@ jobs: ci: - 'project.bootstrap.yaml' - 'AGENTS.md' - - 'CLAUDE.md' - - '.devcontainer/**' + - 'CONTRIBUTING.md' + - '.github/PULL_REQUEST_TEMPLATE.md' - '.githooks/**' - '.github/workflows/**' - 'scripts/**' @@ -82,29 +84,19 @@ jobs: needs: changes if: needs.changes.outputs.app == 'true' || needs.changes.outputs.ci == 'true' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Run fast checks run: bash scripts/ci/run-fast-checks.sh extended-checks: name: Extended Checks - runs-on: ['self-hosted', 'private', 'macOS', 'ARM64', 'xcode'] - timeout-minutes: 40 + runs-on: ['self-hosted', 'linux', 'shell-only', 'public'] + timeout-minutes: 20 needs: changes if: needs.changes.outputs.extended == 'true' || needs.changes.outputs.app == 'true' steps: - - uses: actions/checkout@v4 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Verify vendored OpenSSL build inputs - # Issue #40: Rust now builds OpenSSL through the openssl crate's - # vendored feature, so the macOS runner no longer needs Homebrew, - # pkg-config, or a system OpenSSL prefix. This step keeps clean - # runner failures explicit before the longer Rust build starts. - run: bash scripts/ci/run-extended-validation.sh --check-build-inputs + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Run extended validation run: bash scripts/ci/run-extended-validation.sh @@ -114,7 +106,7 @@ jobs: runs-on: ['self-hosted', 'linux', 'shell-only', 'public'] timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Scan repository for secret patterns run: bash scripts/check-detect-secrets.sh --all-files diff --git a/.github/workflows/pr-fast-ci.yml b/.github/workflows/pr-fast-ci.yml index 6405694..7d1eb2b 100644 --- a/.github/workflows/pr-fast-ci.yml +++ b/.github/workflows/pr-fast-ci.yml @@ -2,10 +2,12 @@ name: PR Fast CI on: pull_request: - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, edited, synchronize, reopened, ready_for_review] + pull_request_review: + types: [submitted, edited, dismissed] concurrency: - group: pr-fast-hosted-${{ github.event.pull_request.number || github.ref }} + group: pr-fast-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true permissions: @@ -27,44 +29,33 @@ jobs: outputs: app: ${{ steps.filter.outputs.app }} ci: ${{ steps.filter.outputs.ci }} - native_app: ${{ steps.filter.outputs.native_app }} - rust_native_e2e: ${{ steps.filter.outputs.rust_native_e2e }} steps: - - uses: dorny/paths-filter@v4 + - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4 id: filter with: filters: | app: - - 'src/**' - - 'rust/**' - - 'native-app/**' - - 'tests/**' - - 'Cargo.toml' - - 'Cargo.lock' - - 'package.json' - - 'package-lock.json' - - 'tsconfig.json' - 'project.bootstrap.yaml' + - 'AGENTS.md' + - 'CONTRIBUTING.md' + - '.github/PULL_REQUEST_TEMPLATE.md' + - '.githooks/**' + - '.github/workflows/**' - 'scripts/**' + - 'docs/bootstrap/**' - 'README.md' - 'docs/**' ci: + - 'project.bootstrap.yaml' + - 'AGENTS.md' + - 'CONTRIBUTING.md' + - '.github/PULL_REQUEST_TEMPLATE.md' + - '.githooks/**' - '.github/workflows/**' - 'scripts/**' + - 'docs/bootstrap/**' - '.env.example' - 'CODEOWNERS' - native_app: - - 'native-app/**' - - 'scripts/build-native-app.sh' - rust_native_e2e: - # Security-sensitive Rust paths that exercise diagnostics, local - # state resolution, or the native broker boundary. - - 'rust/src/bundle.rs' - - 'rust/src/main.rs' - - 'rust/src/native_app.rs' - - 'rust/src/state_root.rs' - - 'rust/src/utils.rs' - - 'rust/tests/native_app_e2e.rs' fast-checks: name: Fast Checks @@ -73,64 +64,148 @@ jobs: needs: changes if: >- github.event.pull_request.draft == false && - ( - needs.changes.outputs.app == 'true' || - needs.changes.outputs.ci == 'true' || - needs.changes.outputs.native_app == 'true' - ) + (needs.changes.outputs.app == 'true' || needs.changes.outputs.ci == 'true') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ github.event.pull_request.head.sha }} - name: Run fast checks run: bash scripts/ci/run-fast-checks.sh - native-app-swift-tests: - name: Native App Swift Tests - # Hosted fallback: the self-hosted macOS/Xcode slots are currently offline, - # and this PR needs the native app compile gate to complete before merge. - runs-on: macos-latest - timeout-minutes: 20 - needs: changes - if: >- - github.event.pull_request.draft == false && - needs.changes.outputs.native_app == 'true' + verify-dependabot-commits: + name: Verify Dependabot-only Commits + runs-on: ['self-hosted', 'linux', 'shell-only', 'public'] + timeout-minutes: 5 + if: github.event.pull_request.draft == false + outputs: + bot_only: ${{ steps.verify.outputs.bot_only }} + env: + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_COMMITS_URL: ${{ github.event.pull_request.commits_url }} + PR_BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_TOKEN: ${{ github.token }} steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} + - id: verify + name: Require Dependabot commits or verified main merges + run: | + set -euo pipefail + page=1 + commits_seen=0 + bot_only=false + merge_tree_repo="$(mktemp -d)" + trap 'rm -rf "$merge_tree_repo"' EXIT + git -C "$merge_tree_repo" init -q + git -C "$merge_tree_repo" remote add origin "https://github.com/$PR_BASE_REPO.git" + git_auth="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')" + git -C "$merge_tree_repo" config http.extraheader "Authorization: Basic $git_auth" + git -C "$merge_tree_repo" fetch --no-tags origin "$PR_BASE_SHA" >/dev/null 2>&1 + base_first_parent_contains() { + git -C "$merge_tree_repo" rev-list --first-parent "$PR_BASE_SHA" | grep -F -x "$1" >/dev/null + } + verify_merge_tree() { + local merge_commit="$1" + local first_parent="$2" + local second_parent="$3" + git -C "$merge_tree_repo" fetch --no-tags origin "$merge_commit" "$first_parent" "$second_parent" >/dev/null 2>&1 || return 1 + local actual_tree + local expected_tree + actual_tree="$(git -C "$merge_tree_repo" rev-parse "$merge_commit^{tree}")" || return 1 + [[ -n "$actual_tree" ]] || return 1 + expected_tree="$(git -C "$merge_tree_repo" merge-tree --write-tree "$first_parent" "$second_parent" 2>/dev/null)" || return 1 + [[ -n "$expected_tree" ]] || return 1 + [[ "$actual_tree" == "$expected_tree" ]] + } + if [[ "$PR_AUTHOR" == "dependabot[bot]" ]]; then + bot_only=true + previous_commit="" + while :; do + response="$(curl --fail-with-body --silent --show-error --location --header "Authorization: Bearer $GITHUB_TOKEN" --header "Accept: application/vnd.github+json" "$PR_COMMITS_URL?per_page=100&page=$page")" + jq -e 'type == "array"' <<<"$response" >/dev/null + count="$(jq 'length' <<<"$response")" + [[ "$count" -gt 0 ]] || break + commits_seen=$((commits_seen + count)) + while IFS= read -r commit; do + commit_sha="$(jq -r '.sha' <<<"$commit")" + first_parent="$(jq -r '.parents[0].sha' <<<"$commit")" + if jq -e '(.committer != null and .committer.login == "web-flow" and .commit.verification.verified == true and .commit.verification.reason == "valid" and .author != null and .author.login == "dependabot[bot]")' <<<"$commit" >/dev/null; then + valid_commit=false + if [[ "$(jq '.parents | length' <<<"$commit")" == 1 ]] && { [[ -z "$previous_commit" ]] && base_first_parent_contains "$first_parent" || [[ "$first_parent" == "$previous_commit" ]]; }; then + valid_commit=true + fi + elif jq -e '(.committer != null and .committer.login == "web-flow" and .commit.verification.verified == true and .commit.verification.reason == "valid" and .author != null and .author.type == "User" and (.parents | length) == 2 and ((.commit.message // "") | test("^Merge branch.*main.*into dependabot/")))' <<<"$commit" >/dev/null; then + second_parent="$(jq -r '.parents[1].sha' <<<"$commit")" + valid_commit=false + if { [[ -z "$previous_commit" ]] && base_first_parent_contains "$first_parent" || [[ "$first_parent" == "$previous_commit" ]]; } && base_first_parent_contains "$second_parent" && verify_merge_tree "$commit_sha" "$first_parent" "$second_parent"; then + valid_commit=true + fi + else + valid_commit=false + fi + if [[ "$valid_commit" != true ]]; then + bot_only=false + fi + previous_commit="$commit_sha" + done < <(jq -c '.[]' <<<"$response") + [[ "$count" -lt 100 ]] && break + page=$((page + 1)) + done + [[ "$commits_seen" -gt 0 ]] || bot_only=false + fi + echo "Verified $commits_seen PR commit(s); Dependabot-only=$bot_only" + echo "bot_only=$bot_only" >> "$GITHUB_OUTPUT" - - name: Run native app xcodebuild tests - working-directory: native-app - run: >- - xcodebuild - -scheme APW-Package - -destination 'platform=macOS' - -derivedDataPath .xcode-derived - test - - rust-native-e2e: - name: Rust Native App E2E - # Use an isolated hosted runner for PR code. The private macOS pool is - # capacity-constrained and must not become a reliability or trust-boundary - # dependency for this required gate. - runs-on: macos-latest - timeout-minutes: 30 - needs: changes - if: >- - github.event.pull_request.draft == false && - needs.changes.outputs.rust_native_e2e == 'true' + validate-pr-description: + name: Validate PR Description + runs-on: ['self-hosted', 'linux', 'shell-only', 'public'] + timeout-minutes: 5 + if: github.event.pull_request.draft == false && needs.verify-dependabot-commits.outputs.bot_only != 'true' + needs: verify-dependabot-commits + env: + PR_BODY: ${{ github.event.pull_request.body }} steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} + - name: Require generated PR template content + run: | + failed=0 - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable + require_line() { + local line="$1" + if ! grep -Fqx "$line" <<<"$PR_BODY"; then + echo "Missing required PR section: $line" + failed=1 + fi + } + + require_line "## Summary" + require_line "## Governing Issue" + require_line "## Validation" + require_line "## Bootstrap Governance" + require_line "## Merge Automation" + require_line "## Notes" + + if grep -Eiq 'Closes #$|#|what changed|why it changed|notable tradeoffs|migration or rollout notes|follow-up work if any' <<<"$PR_BODY"; then + echo "PR body still contains template placeholder text." + failed=1 + fi + + if ! grep -Eiq '(^|[[:space:]-])(((close[sd]?|fix(e[sd])?|resolve[sd]?|refs?|part[[:space:]]+of)[[:space:]]+)?(#|[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#|https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/issues/)[0-9]+|no issue is linked|no linked issue|without a linked issue|no governing issue)' <<<"$PR_BODY"; then + echo "PR body must close/link an issue or explicitly explain why no issue is linked." + failed=1 + fi - - name: Run Rust native app end-to-end tests - run: cargo test --manifest-path rust/Cargo.toml --test native_app_e2e + if ! grep -Eiq '(^|[[:space:]-])(\[[xX]\]|not run|not applicable|n/a)' <<<"$PR_BODY"; then + echo "PR body must include validation evidence, a checked validation item, or a reason validation was not run." + failed=1 + fi + + auto_merge_evidence="$(grep -Eiv '^[[:space:]]*-[[:space:]]+\[[[:space:]]\][[:space:]]' <<<"$PR_BODY" || true)" + if ! grep -Eiq 'auto-merge (is )?(enabled|armed)|enabled auto-merge|gh pr merge --auto|auto_merge|auto merge enabled|auto-merge (is )?(unavailable|unsafe|not available|not safe)|plan-limit|fallback merge-readiness' <<<"$auto_merge_evidence"; then + echo "PR body must state that the PR author enabled auto-merge, or explain why auto-merge is unavailable/unsafe." + failed=1 + fi + + exit "$failed" validate-secrets: name: Validate Secrets @@ -138,12 +213,47 @@ jobs: timeout-minutes: 10 if: github.event.pull_request.draft == false steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ github.event.pull_request.head.sha }} - name: Scan repository for secret patterns run: bash scripts/check-detect-secrets.sh --all-files + validate-pr-governance: + name: Validate PR Governance + runs-on: ['self-hosted', 'linux', 'shell-only', 'public'] + timeout-minutes: 5 + if: github.event.pull_request.draft == false && needs.verify-dependabot-commits.outputs.bot_only != 'true' + needs: verify-dependabot-commits + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_CREATED_AT: ${{ github.event.pull_request.created_at }} + PR_GOVERNANCE_ENFORCE_AFTER: '' + PR_FILES_URL: ${{ github.event.pull_request.url }}/files + PR_COMMITS_URL: ${{ github.event.pull_request.commits_url }} + PR_REVIEWS_URL: ${{ github.event.pull_request.url }}/reviews + GITHUB_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: Validate title, DCO, size, ADR, and reviewer evidence + run: bash scripts/ci/check-pr-governance.sh + + validate-action-pins: + name: Validate Action Pins + runs-on: ['self-hosted', 'linux', 'shell-only', 'public'] + timeout-minutes: 5 + if: github.event.pull_request.draft == false + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: Require immutable third-party action pins + run: bash scripts/ci/check-action-pins.sh + ci-gate: name: CI Gate runs-on: ['self-hosted', 'linux', 'shell-only', 'public'] @@ -151,18 +261,22 @@ jobs: needs: - changes - fast-checks - - native-app-swift-tests - - rust-native-e2e + - verify-dependabot-commits + - validate-pr-description - validate-secrets + - validate-pr-governance + - validate-action-pins steps: - name: Check required PR jobs env: RESULTS: >- changes=${{ needs.changes.result }} fast-checks=${{ needs.fast-checks.result }} - native-app-swift-tests=${{ needs.native-app-swift-tests.result }} - rust-native-e2e=${{ needs.rust-native-e2e.result }} + verify-dependabot-commits=${{ needs.verify-dependabot-commits.result }} + validate-pr-description=${{ needs.validate-pr-description.result }} validate-secrets=${{ needs.validate-secrets.result }} + validate-pr-governance=${{ needs.validate-pr-governance.result }} + validate-action-pins=${{ needs.validate-action-pins.result }} run: | failed=0 for entry in $RESULTS; do diff --git a/AGENTS.md b/AGENTS.md index 64e7bc8..3ba492e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,9 @@ - Always work on a feature branch. Hooks block commits to `main` and `master`; enable them with `git config core.hooksPath .githooks`. - Stack baseline: Generic polyglot. - CI baseline: fast PR checks stay cheap and shell-safe; extended validation runs on `main`, nightly, or manual dispatch. -- Self-hosted runner policy: shell-safe jobs must use `[self-hosted, linux, shell-only, public]`; native repos must use self-hosted runners for required automation, with Docker, service-container, browser, or `container:` workloads routed to dedicated self-hosted capability pools. +- Self-hosted runner policy: private-repository trusted jobs may use their matching capability pool. Public repository security workflows use GitHub-hosted isolation; fork pull-request jobs always remain read-only and GitHub-hosted. - Add or update tests for every interactive, branching, or operator-facing behavior change. +- For a task that may open or update a PR, handle autoreview access before implementation: request required network access immediately and, for a private repository, explicit authorization to send the forthcoming intended PR diff to the external reviewer. At closeout, use the `autoreview` skill against the actual base. Verify every finding, fix accepted in-scope findings, and rerun affected tests and autoreview after changes. Proceed only when no accepted/actionable findings remain, and record the final command and result in the PR validation evidence. If authorization is declined or the skill is unavailable or cannot complete, stop and report the blocker instead of bypassing the gate. - PRs must use the generated pull request template. The required PR gate validates summary, issue linkage, validation evidence, and risk notes. - Never commit real secrets, runtime auth, or machine-local env files. Use templates and GitHub environments instead. diff --git a/SECURITY.md b/SECURITY.md index d58d2b6..000215e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,14 @@ This repository follows the bootstrap-managed security baseline for OMT-Global/a ## Reporting -Open a private security advisory or contact the repository maintainers before disclosing a vulnerability publicly. +Report suspected vulnerabilities through [GitHub private vulnerability reporting](https://github.com/OMT-Global/apw-cli/security/advisories/new). If that form is unavailable, open a public issue titled `Private security contact requested` without vulnerability details; maintainers will establish a confidential channel before accepting the report. Never include exploit details in public issues or discussions. + +## Response Targets + +- Acknowledge a complete report within 3 business days. +- Provide a status update within 10 business days, even when investigation is ongoing. +- Target remediation within 7 days for critical findings, 30 days for high findings, and 90 days for moderate findings. Low-severity findings are scheduled by maintainers. +- Coordinate disclosure timing with the reporter after a fix or documented mitigation is available. ## Baseline diff --git a/docs/bootstrap/issue-hygiene.md b/docs/bootstrap/issue-hygiene.md new file mode 100644 index 0000000..371de70 --- /dev/null +++ b/docs/bootstrap/issue-hygiene.md @@ -0,0 +1,31 @@ +# Report-First Issue Hygiene + +`.github/workflows/issue-hygiene.yml` inventories open issues every Monday and on manual dispatch. It uses only `contents: read` and `issues: read`, writes a complete versioned JSON artifact, and appends a Markdown report capped at 900 KiB to the workflow summary. + +## Aging Rules + +- Fewer than 30 inactive days: current; no report entry. +- At least 30 inactive days: review proposal. +- At least 90 inactive days without a credible next action: close-or-rescope proposal that requires a maintainer decision. +- Automation never comments, labels, closes, reschedules, or otherwise mutates an issue. + +GitHub's `updated_at` timestamp is the inactivity source. Pull requests returned by the issues API are excluded. + +## Preserve A Stale Issue + +Add one structured marker to the issue body. `outcome` or an evidence-shaped `dependency` is required, `checkpoint` must be a future ISO date, and `evidence` must be a canonical public GitHub issue, pull-request, or Actions-run URL without query or fragment data, or a positive numeric `issue:`, `pr:`, or `run:` reference. + +```html + +``` + +The report publishes only the issue number, single-line title, URL, timestamps, checkpoint, and evidence reference. It never emits the issue body or the next-action outcome. + +## Local Fixture + +```sh +node scripts/ci/report-issue-hygiene.mjs \ + --fixture /path/to/issues.json \ + --as-of 2026-07-18T12:00:00Z \ + --json-output issue-hygiene-report.json +``` diff --git a/docs/bootstrap/onboarding.md b/docs/bootstrap/onboarding.md index a58a08f..1d8a922 100644 --- a/docs/bootstrap/onboarding.md +++ b/docs/bootstrap/onboarding.md @@ -12,12 +12,20 @@ Use this checklist after the first bootstrap render or whenever `project.bootstr - Confirm branch protection or rulesets on `main` require one approval, code owner review, and approval from someone other than the most recent pusher. - Confirm branch protection points at the `CI Gate` status. - Confirm `CONTRIBUTING.md` and `.github/PULL_REQUEST_TEMPLATE.md` are present as the required contributor and PR guidance surfaces. +- Confirm `AGENTS.md` requires the `autoreview` skill against the intended PR diff before an agent opens or updates a PR, and that the PR template records the final command and result. - Confirm the pull request template is present and PR Fast CI validates the required PR description sections before CI Gate can pass. +- Confirm `Issue Hygiene Report` runs weekly with read-only issue permission and retains its JSON evidence artifact. - Confirm `delete branch on merge` and `allow auto-merge` are enabled when the GitHub plan supports them; otherwise record the plan-limit evidence and use the fallback merge-readiness policy. - Fallback merge readiness requires passing or intentionally skipped required checks, satisfied approvals, resolved conversations, no blocking review state, and a manual maintainer merge. +## Public Security Baseline + +- Review `docs/bootstrap/security.md` before changing security workflow events, permissions, or runner labels. +- Confirm dependency review is the only security job reachable from fork pull requests and runs on GitHub-hosted isolation; CodeQL and SBOM jobs must remain trusted-event only and GitHub-hosted. +- Capture the seven required GitHub capability observations before treating remote security controls as verified. +- Confirm `SECURITY.md` private reporting and response targets match the maintained operational policy. ## Environments @@ -27,8 +35,9 @@ Use this checklist after the first bootstrap render or whenever `project.bootstr ## Runner Policy -- Shell-safe jobs must use `[self-hosted, linux, shell-only, public]`. -- Native repos must use self-hosted runners for required automation; Docker, service-container, browser, and `container:` workloads require a dedicated self-hosted runner pool with matching capability labels. +- Private-repository trusted shell-safe jobs use `[self-hosted, linux, shell-only, private]`. +- Public repository security workflows use GitHub-hosted isolation. Fork pull-request jobs always remain read-only and GitHub-hosted. +- Native repos must use self-hosted runners for trusted required automation; Docker, service-container, browser, and `container:` workloads require a dedicated self-hosted runner pool with matching capability labels. - Keep PR checks cheap. Add heavy validation to `scripts/ci/run-extended-validation.sh` instead of the PR lane. - Consume shared security, release, and AI attestation workflows from the control-plane repo once those contracts are pinned for production use. @@ -40,6 +49,20 @@ Use this checklist after the first bootstrap render or whenever `project.bootstr - To retrofit an existing bootstrapped repo, add `CONTRIBUTING.md` and `.github/PULL_REQUEST_TEMPLATE.md` to `repo.managedPaths` when that repo restricts managed paths, then run `bootstrap apply repo --manifest ./project.bootstrap.yaml`. - Keep these files repo-generic unless project metadata or the manifest requires a stricter local rule. +## Issue Hygiene + +- Review `docs/bootstrap/issue-hygiene.md` before acting on a 30-day review or 90-day close-or-rescope proposal. +- The scheduled workflow is report-only: it never comments, labels, closes, or reschedules issues. +- A 90-day proposal always requires a maintainer decision. Record a structured, evidenced future action when the issue should remain open. + +## Licensing + +- Repository visibility never selects or grants a license. Declare `license.mode` explicitly before Bootstrap manages `LICENSE`. +- Current manifest mode: not declared; Bootstrap will not create, replace, or remove a license. +- Keep `THIRD_PARTY_NOTICES.md` separate from the first-party notice and inventory dependencies, assets, fonts, media, and incorporated source. +- Any existing-license replacement requires legal ownership, contributor, distribution-history, issue, and approver evidence in the manifest. Previously granted rights are not revoked. +- Verify GitHub license detection after publishing an SPDX license. Never describe a proprietary notice as SPDX, OSI approved, or GitHub-recognized. + ## Fleet Reconciliation - Run `bootstrap reconcile --workspace-root ~/src --report bootstrap-reconcile.json` first; this is plan-only and does not write files. diff --git a/docs/bootstrap/security.md b/docs/bootstrap/security.md new file mode 100644 index 0000000..bd01a5a --- /dev/null +++ b/docs/bootstrap/security.md @@ -0,0 +1,22 @@ +# Public Repository Security Model + +## Trust Boundaries + +- Pull requests, including forks, are untrusted input. The pull-request lane runs on GitHub-hosted isolation with read-only repository permissions, does not read GitHub Actions secrets, and runs only dependency review after GitHub provisioning enables the dependency graph and sets `DEPENDENCY_REVIEW_ENABLED=true`. +- Code scanning and SBOM generation run only for trusted default-branch pushes and schedules on GitHub-hosted isolation. +- GitHub-hosted security capabilities are evaluated from a versioned capability snapshot so unsupported plan features remain distinct from repository misconfiguration. + +## Required Controls + +- Dependency graph, Dependabot alerts and security updates, secret scanning, push protection, code scanning, and private vulnerability reporting are required capability observations for public repositories. The dependency-graph observation must also record `dependencyReviewEnabled: true` after provisioning verifies `DEPENDENCY_REVIEW_ENABLED=true`. +- `.github/dependabot.yml` keeps both dependency and GitHub Actions pins updateable. +- `.github/workflows/security.yml` performs dependency review, CodeQL analysis for `javascript-typescript,rust,swift`, and SPDX JSON SBOM generation using immutable action SHAs. +- `SECURITY.md` directs reporters to a private advisory and defines acknowledgement, update, remediation, and coordinated-disclosure targets. + +## Fork Safety + +The security workflow uses `pull_request`, never `pull_request_target`. Its top-level permission is `contents: read`; the only job reachable from a pull request uses a GitHub-hosted runner, has read-only permissions, and has no secret references. Jobs needing `security-events: write` are explicitly excluded from pull-request events. + +## Capability Evidence + +Capture authorized observations for these controls and pass them to `bootstrap conform --github-capabilities `: `dependency-graph`, `dependabot-alerts`, `dependabot-security-updates`, `secret-scanning`, `push-protection`, `code-scanning`, and `private-vulnerability-reporting`. Record `dependencyReviewEnabled: true` only after verifying the repository activation variable. Unsupported controls remain warnings with remediation; available but disabled controls are blocking misconfigurations. Current typed exceptions may waive only their matching `github.` scope. diff --git a/project.bootstrap.yaml b/project.bootstrap.yaml index 62b6e2c..6192023 100644 --- a/project.bootstrap.yaml +++ b/project.bootstrap.yaml @@ -173,6 +173,10 @@ ci: extendedChecks: - template-review nightlyCron: 0 7 * * * + codeqlLanguages: + - javascript-typescript + - rust + - swift additionalWorkflows: [] workflows: prFastCi: true @@ -236,6 +240,7 @@ capabilities: enabled: false containers: enabled: false +exceptions: [] environments: dev: reviewers: [] diff --git a/scripts/check-detect-secrets.sh b/scripts/check-detect-secrets.sh index 7d00917..f127c75 100755 --- a/scripts/check-detect-secrets.sh +++ b/scripts/check-detect-secrets.sh @@ -1,80 +1,80 @@ -#!/usr/bin/env bash -set -euo pipefail + #!/usr/bin/env bash + set -euo pipefail -mode="${1:---all-files}" - -ignore_globs=("scripts/check-detect-secrets.sh") -if [[ -f .detect-secrets-ignore ]]; then - while IFS= read -r ignore_glob; do - if [[ -z "$ignore_glob" ]] || [[ "${ignore_glob:0:1}" == "#" ]]; then - continue + mode="${1:-"--all-files"}" + ignore_globs=("scripts/check-detect-secrets.sh") + if [[ -f .detect-secrets-ignore ]]; then + while IFS= read -r ignore_glob; do + if [[ -z "$ignore_glob" ]]; then + continue + fi + if [[ "${ignore_glob:0:1}" == "#" ]]; then + continue + fi + ignore_globs+=("$ignore_glob") + done < .detect-secrets-ignore fi - ignore_globs+=("$ignore_glob") - done < .detect-secrets-ignore -fi -should_skip_file() { - local candidate="$1" - local ignore_glob - for ignore_glob in "${ignore_globs[@]}"; do - case "$candidate" in - $ignore_glob) - return 0 - ;; - esac - done - return 1 -} + should_skip_file() { + local candidate="$1" + local ignore_glob + for ignore_glob in "${ignore_globs[@]}"; do + case "$candidate" in + $ignore_glob) + return 0 + ;; + esac + done + return 1 + } -files=() -if [[ "$mode" == "--staged" ]]; then - while IFS= read -r -d '' file; do - files+=("$file") - done < <(git diff --cached --name-only --diff-filter=ACMR -z) -else - while IFS= read -r -d '' file; do - files+=("$file") - done < <(git ls-files -z) -fi + files=() + if [[ "$mode" == "--staged" ]]; then + while IFS= read -r -d '' file; do + files+=("$file") + done < <(git diff --cached --name-only --diff-filter=ACMR -z) + else + while IFS= read -r -d '' file; do + files+=("$file") + done < <(git ls-files -z) + fi -if [[ "${#files[@]}" -eq 0 ]]; then - echo "No files to scan." - exit 0 -fi + if [[ "${#files[@]}" -eq 0 ]]; then + echo "No files to scan." + exit 0 + fi -patterns=( - 'ghp_' - 'github_pat_' - 'sk-live-' - 'sk-proj-' - 'AKIA[0-9A-Z]{16}' - 'BEGIN (RSA|OPENSSH|EC) PRIVATE KEY' - 'ANTHROPIC_API_KEY=' - 'OPENAI_API_KEY=' - 'SUDO_PASS=' - 'BW_SESSION=' -) + patterns=( + 'ghp_' + 'github_pat_' + 'sk-live-' + 'sk-proj-' + 'AKIA[0-9A-Z]{16}' + 'BEGIN (RSA|OPENSSH|EC) PRIVATE KEY' + 'OPENAI_API_KEY=' + 'SUDO_PASS=' + 'BW_SESSION=' + ) -tmp_file="$(mktemp)" -trap 'rm -f "$tmp_file"' EXIT + tmp_file="$(mktemp)" + trap 'rm -f "$tmp_file"' EXIT -for file in "${files[@]}"; do - if [[ ! -f "$file" ]] || should_skip_file "$file"; then - continue - fi - printf '%s\n' "$file" >>"$tmp_file" -done + for file in "${files[@]}"; do + if [[ ! -f "$file" ]] || should_skip_file "$file"; then + continue + fi + printf '%s +' "$file" >>"$tmp_file" + done -failed=0 -while IFS= read -r file; do - for pattern in "${patterns[@]}"; do - if grep -E -q "$pattern" "$file" 2>/dev/null; then - grep -E -n "$pattern" "$file" 2>/dev/null | while IFS= read -r match; do - echo "Potential secret pattern '$pattern' found in $file:$match" >&2 + failed=0 + while IFS= read -r file; do + for pattern in "${patterns[@]}"; do + if grep -E -n "$pattern" "$file" >/dev/null 2>&1; then + echo "Potential secret pattern '$pattern' found in $file" >&2 + failed=1 + fi done - failed=1 - fi - done -done <"$tmp_file" + done <"$tmp_file" -exit "$failed" + exit "$failed" diff --git a/scripts/ci/check-action-pins.sh b/scripts/ci/check-action-pins.sh new file mode 100755 index 0000000..8638580 --- /dev/null +++ b/scripts/ci/check-action-pins.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +python3 - "${1:-.github/workflows}" <<'PY' +import re +import sys +from pathlib import Path + +workflow_root = Path(sys.argv[1]) +uses_pattern = re.compile(r"^\s*(?:-\s+)?uses:\s*([^\s#]+)@([^\s#]+)(?:\s+#\s*(.+))?\s*$") +sha_pattern = re.compile(r"^[0-9a-f]{40}$") +failures = [] +checked = 0 + +for workflow in sorted([*workflow_root.rglob("*.yml"), *workflow_root.rglob("*.yaml")]): + for line_number, line in enumerate(workflow.read_text().splitlines(), start=1): + match = uses_pattern.match(line) + if not match: + continue + action, ref, metadata = match.groups() + if action.startswith("./") or action.startswith("OMT-Global/bootstrap/"): + continue + checked += 1 + location = f"{workflow}:{line_number}" + if not sha_pattern.fullmatch(ref): + failures.append(f"SA-ACTION-PIN-001 {location}: {action}@{ref} is not an immutable 40-character commit SHA.") + elif not metadata: + failures.append(f"SA-ACTION-PIN-002 {location}: {action} is pinned but lacks readable tag or release metadata after '#'.") + +if failures: + print("\n".join(failures), file=sys.stderr) + raise SystemExit(1) + +print(f"PASS SA-ACTION-PIN-000: validated {checked} third-party action pin(s) under {workflow_root}.") +PY diff --git a/scripts/ci/check-pr-governance.sh b/scripts/ci/check-pr-governance.sh new file mode 100755 index 0000000..f95a34e --- /dev/null +++ b/scripts/ci/check-pr-governance.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +required=(PR_TITLE PR_BODY PR_AUTHOR) +for name in "${required[@]}"; do + if [[ -z "${!name:-}" ]]; then + echo "Missing required PR governance input: $name" >&2 + exit 2 + fi +done + +workdir="$(mktemp -d)" +trap 'rm -rf "$workdir"' EXIT + +fetch() { + local url="$1" + local destination="$2" + curl --fail --silent --show-error --location --retry 2 \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $GITHUB_TOKEN" \ + "$url" >"$destination" +} + +load_response() { + local fixture="$1" + local url="$2" + local suffix="$3" + local destination="$4" + if [[ -n "$fixture" ]]; then + cp "$fixture" "$destination" + elif [[ -n "$url" && -n "${GITHUB_TOKEN:-}" ]]; then + fetch "$url?$suffix" "$destination" + else + echo "Provide a fixture file or API URL plus GITHUB_TOKEN for $destination" >&2 + exit 2 + fi +} + +load_response "${PR_FILES_FILE:-}" "${PR_FILES_URL:-}" "per_page=100" "$workdir/files.json" +load_response "${PR_COMMITS_FILE:-}" "${PR_COMMITS_URL:-}" "per_page=250" "$workdir/commits.json" +load_response "${PR_REVIEWS_FILE:-}" "${PR_REVIEWS_URL:-}" "per_page=100" "$workdir/reviews.json" + +python3 - "$PR_TITLE" "$PR_BODY" "$PR_AUTHOR" "${PR_CREATED_AT:-}" "${PR_GOVERNANCE_ENFORCE_AFTER:-}" "$workdir/files.json" "$workdir/commits.json" "$workdir/reviews.json" <<'PY' +from datetime import datetime +import json +import re +import sys +from pathlib import Path + +title, body, author, created_at, enforce_after, files_path, commits_path, reviews_path = sys.argv[1:] +files = json.loads(Path(files_path).read_text()) +commits = json.loads(Path(commits_path).read_text()) +reviews = json.loads(Path(reviews_path).read_text()) +failures = [] + +if enforce_after: + try: + created_at_value = datetime.fromisoformat(created_at.replace("Z", "+00:00")) + enforce_after_value = datetime.fromisoformat(enforce_after.replace("Z", "+00:00")) + if created_at_value.tzinfo is None or enforce_after_value.tzinfo is None: + raise ValueError + except ValueError: + failures.append("PRS-ENFORCEMENT-INPUT-001: PR_CREATED_AT and PR_GOVERNANCE_ENFORCE_AFTER must be ISO-8601 timestamps.") + else: + if created_at_value < enforce_after_value: + print(f"PASS PRS-PR-GOVERNANCE-LEGACY-001: PR opened at {created_at} before enforcement began at {enforce_after}.") + sys.exit(0) + +if not re.match(r"^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([^)]+\))?!?: .+", title): + failures.append("PRS-PR-TITLE-001: use a Conventional Commit-style PR title, for example 'feat: add policy gate'.") + +excluded = [] +counted_lines = 0 +for changed in files: + name = changed.get("filename", "unknown") + if name.startswith(("docs/", "test/", "tests/")) or name.endswith((".md", ".lock")) or name in {"package-lock.json", "pnpm-lock.yaml", "yarn.lock"}: + excluded.append(name) + else: + counted_lines += int(changed.get("additions", 0)) + int(changed.get("deletions", 0)) +print(f"INFO PRS-PR-SIZE-001: {counted_lines} counted changed lines; excluded {len(excluded)} documentation, test, and lockfile paths.") +if excluded: + print("INFO PRS-PR-SIZE-001 excluded: " + ", ".join(sorted(excluded))) +if counted_lines > 800: + failures.append(f"PRS-PR-SIZE-001: {counted_lines} counted changed lines exceeds the 800-line review threshold; split the change before requesting review.") + +missing_dco = [] +for commit in commits: + login = (commit.get("author") or {}).get("login", "") + account_type = (commit.get("author") or {}).get("type", "") + if account_type == "Bot" or login.endswith("[bot]"): + continue + message = (commit.get("commit") or {}).get("message", "") + if not re.search(r"(?im)^signed-off-by:\s+.+ <[^>]+>$", message): + missing_dco.append(commit.get("sha", "unknown")[:12]) +if missing_dco: + failures.append("PRS-DCO-001: contributed commits without a Signed-off-by trailer: " + ", ".join(missing_dco)) + +declaration = re.search(r"(?im)^material change:\s*(yes|no)\s*$", body) +if not declaration: + failures.append("PRS-MATERIAL-001: declare 'Material change: yes' or 'Material change: no' in the PR body.") +elif declaration.group(1).lower() == "yes": + adr = re.search(r"(?im)^adr:\s*(docs/decisions/[^\s]+\.md)\s*$", body) + if not adr: + failures.append("PRS-ADR-001: material changes require an ADR line pointing at an accepted docs/decisions/*.md file.") + else: + adr_path = Path(adr.group(1)) + if not adr_path.is_file() or not re.search(r"(?im)^status:\s*accepted\s*$", adr_path.read_text()): + failures.append(f"PRS-ADR-001: {adr_path} must exist in this PR and declare 'Status: Accepted'.") + + independent_approvers = { + (review.get("user") or {}).get("login", "") + for review in reviews + if review.get("state", "").upper() == "APPROVED" + and (review.get("user") or {}).get("login", "") != author + and (review.get("user") or {}).get("type", "") != "Bot" + } + if not independent_approvers: + failures.append("PRS-INDEPENDENT-REVIEW-001: material changes require an approving reviewer other than the PR author.") + +if failures: + print("\n".join("FAIL " + failure for failure in failures), file=sys.stderr) + sys.exit(1) +print("PASS PRS-PR-GOVERNANCE-001: title, DCO, change accounting, and material evidence are valid.") +PY diff --git a/scripts/ci/report-issue-hygiene.mjs b/scripts/ci/report-issue-hygiene.mjs new file mode 100755 index 0000000..4145e3d --- /dev/null +++ b/scripts/ci/report-issue-hygiene.mjs @@ -0,0 +1,252 @@ +#!/usr/bin/env node +import { readFile, writeFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +const INACTIVE_REVIEW_DAYS = 30; +const CLOSE_OR_RESCOPE_DAYS = 90; +const MARKDOWN_SUMMARY_BYTE_LIMIT = 900 * 1024; +const NEXT_ACTION_PATTERN = //gi; + +function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (!["--fixture", "--repo", "--as-of", "--json-output"].includes(argument)) { + throw new Error(`Unknown argument: ${argument}`); + } + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${argument} requires a value.`); + options[argument.slice(2).replace("-", "_")] = value; + index += 1; + } + if (Boolean(options.fixture) === Boolean(options.repo)) { + throw new Error("Provide exactly one of --fixture or --repo."); + } + return options; +} + +function parseTimestamp(value, label) { + if (typeof value !== "string") throw new Error(`${label} must be an ISO-8601 timestamp.`); + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2}))$/); + if (!match) throw new Error(`${label} must be an ISO-8601 timestamp.`); + const [, yearText, monthText, dayText, hourText, minuteText, secondText, zone, offsetHourText, offsetMinuteText] = match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + const validDate = month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth[month - 1]; + const validTime = Number(hourText) <= 23 && Number(minuteText) <= 59 && Number(secondText) <= 59; + const offsetHour = Number(offsetHourText ?? 0); + const offsetMinute = Number(offsetMinuteText ?? 0); + const validOffset = zone === "Z" || (offsetHour <= 14 && offsetMinute <= 59 && (offsetHour < 14 || offsetMinute === 0)); + if (!validDate || !validTime || !validOffset) throw new Error(`${label} must be an ISO-8601 timestamp.`); + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) throw new Error(`${label} must be an ISO-8601 timestamp.`); + return timestamp; +} + +function normalizeIssue(raw) { + if (!raw || typeof raw !== "object") throw new Error("Issue records must be objects."); + const number = raw.number; + const title = raw.title; + const url = raw.html_url ?? raw.url; + const updatedAt = raw.updated_at ?? raw.updatedAt; + if (!Number.isInteger(number) || number <= 0) throw new Error("Issue number must be a positive integer."); + if (typeof title !== "string" || !title.trim()) throw new Error(`Issue #${number} title is required.`); + if (typeof url !== "string" || !/^https:\/\//.test(url)) throw new Error(`Issue #${number} URL must use HTTPS.`); + parseTimestamp(updatedAt, `Issue #${number} updatedAt`); + return { + number, + title: title.replace(/\s+/g, " ").trim(), + url, + updatedAt, + body: typeof raw.body === "string" ? raw.body : "", + isPullRequest: raw.pull_request !== undefined || raw.isPullRequest === true + }; +} + +function parseCheckpoint(value) { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return Number.NaN; + const [year, month, day] = value.split("-").map(Number); + const timestamp = Date.UTC(year, month - 1, day); + const parsed = new Date(timestamp); + return parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day + ? timestamp + : Number.NaN; +} + +function escapeMarkdownText(value) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\\/g, "\\\\") + .replace(/([`*_[\]{}()#+.!|])/g, "\\$1"); +} + +function validEvidenceReference(value) { + if (typeof value !== "string" || /\s/.test(value)) return false; + if (/^(?:issue|pr|run):[1-9]\d*$/.test(value)) return true; + try { + const url = new URL(value); + const publicGitHubEvidence = /^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/(?:issues\/[1-9]\d*|pull\/[1-9]\d*|actions\/runs\/[1-9]\d*)$/.test(url.pathname); + return url.origin === "https://github.com" && !url.username && !url.password && !url.search && !url.hash && publicGitHubEvidence; + } catch { + return false; + } +} + +function credibleNextAction(body, asOfTimestamp) { + let credible; + for (const match of body.matchAll(NEXT_ACTION_PATTERN)) { + try { + const value = JSON.parse(match[1]); + const outcome = typeof value.outcome === "string" && value.outcome.trim().length > 0; + const dependency = validEvidenceReference(value.dependency); + const checkpoint = parseCheckpoint(value.checkpoint); + const evidence = validEvidenceReference(value.evidence); + if ((outcome || dependency) && Number.isFinite(checkpoint) && checkpoint > asOfTimestamp && evidence) { + credible = { checkpoint: value.checkpoint, evidence: value.evidence }; + } + } catch { + // Malformed markers are not evidence and remain reportable at the normal aging threshold. + } + } + return credible; +} + +export function buildIssueHygieneReport(rawIssues, asOf = new Date().toISOString()) { + const asOfTimestamp = parseTimestamp(asOf, "asOf"); + const issues = rawIssues.map(normalizeIssue).filter((issue) => !issue.isPullRequest).sort((left, right) => left.number - right.number); + const results = []; + let current = 0; + let review = 0; + let closeOrRescope = 0; + + for (const issue of issues) { + const inactiveDays = Math.max(0, Math.floor((asOfTimestamp - parseTimestamp(issue.updatedAt, `Issue #${issue.number} updatedAt`)) / 86_400_000)); + if (inactiveDays < INACTIVE_REVIEW_DAYS) { + current += 1; + continue; + } + + const nextAction = credibleNextAction(issue.body, asOfTimestamp); + const proposedAction = inactiveDays >= CLOSE_OR_RESCOPE_DAYS && !nextAction ? "close-or-rescope" : "review"; + const humanDecisionRequired = proposedAction === "close-or-rescope"; + if (humanDecisionRequired) closeOrRescope += 1; + else review += 1; + + results.push({ + ruleId: "PRS-ISSUE-AGING-001", + severity: "warning", + issue: { number: issue.number, title: issue.title, url: issue.url }, + inactiveDays, + proposedAction, + humanDecisionRequired, + mutationAllowed: false, + evidence: [ + `updatedAt=${issue.updatedAt}`, + ...(nextAction ? [`nextActionCheckpoint=${nextAction.checkpoint}`, `nextActionEvidence=${nextAction.evidence}`] : []) + ], + remediation: humanDecisionRequired + ? "A maintainer must close or rescope this issue, or record a credible evidenced next action with a future checkpoint." + : "Review the issue and record a credible evidenced next action with a future checkpoint when work remains." + }); + } + + return { + schemaVersion: 1, + asOf: new Date(asOfTimestamp).toISOString(), + thresholds: { inactiveReviewDays: INACTIVE_REVIEW_DAYS, closeOrRescopeDays: CLOSE_OR_RESCOPE_DAYS }, + summary: { scanned: issues.length, current, review, closeOrRescope }, + results + }; +} + +export function formatIssueHygieneReport(report) { + const lines = [ + `# Issue Hygiene Report`, + "", + `Scanned ${report.summary.scanned} open issues: ${report.summary.current} current, ${report.summary.review} review, ${report.summary.closeOrRescope} close-or-rescope proposal(s).`, + "", + "This report never mutates, closes, labels, or reschedules an issue. Every close-or-rescope proposal requires a maintainer decision." + ]; + let omitted = 0; + for (let index = 0; index < report.results.length; index += 1) { + const result = report.results[index]; + const entry = `- [${result.proposedAction}] [#${result.issue.number}](${result.issue.url}) ${escapeMarkdownText(result.issue.title)} — ${result.inactiveDays} inactive days; ${result.remediation}`; + const omittedIfRejected = report.results.length - index; + const reservedNotice = `\n\n${omittedIfRejected} additional report entries omitted from Markdown; see the complete JSON artifact.`; + const candidate = `${[...lines, "", entry].join("\n")}${reservedNotice}\n`; + if (Buffer.byteLength(candidate, "utf8") > MARKDOWN_SUMMARY_BYTE_LIMIT) { + omitted = report.results.length - index; + break; + } + lines.push("", entry); + } + if (omitted > 0) lines.push("", `${omitted} additional report entries omitted from Markdown; see the complete JSON artifact.`); + return `${lines.join("\n")}\n`; +} + +function nextPageUrl(linkHeader, repo) { + if (!linkHeader) return undefined; + for (const link of linkHeader.split(",")) { + const match = link.match(/^\s*<([^>]+)>\s*;\s*rel="([^"]+)"\s*$/); + if (match?.[2].split(/\s+/).includes("next")) { + const url = new URL(match[1]); + const expectedNamedPath = `/repos/${repo}/issues`; + const isCanonicalIdPath = /^\/repositories\/\d+\/issues$/.test(url.pathname); + if (url.origin !== "https://api.github.com" || (url.pathname !== expectedNamedPath && !isCanonicalIdPath)) { + throw new Error("GitHub issue inventory returned an invalid next-page URL."); + } + return url.href; + } + } + return undefined; +} + +export async function fetchOpenIssues(repo, token, fetchImplementation = fetch) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new Error("--repo must be owner/name."); + if (!token) throw new Error("GITHUB_TOKEN is required with --repo."); + const issues = []; + const visited = new Set(); + let url = `https://api.github.com/repos/${repo}/issues?state=open&per_page=100`; + while (url) { + if (visited.has(url)) throw new Error("GitHub issue inventory returned a pagination loop."); + visited.add(url); + const response = await fetchImplementation(url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }); + if (!response.ok) throw new Error(`GitHub issue inventory failed with HTTP ${response.status}.`); + const pageIssues = await response.json(); + if (!Array.isArray(pageIssues)) throw new Error("GitHub issue inventory returned a non-array response."); + issues.push(...pageIssues); + url = nextPageUrl(response.headers.get("link"), repo); + } + return issues; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const asOf = options.as_of ?? new Date().toISOString(); + const raw = options.fixture + ? JSON.parse(await readFile(options.fixture, "utf8")) + : await fetchOpenIssues(options.repo, process.env.GITHUB_TOKEN); + const issues = Array.isArray(raw) ? raw : raw?.issues; + if (!Array.isArray(issues)) throw new Error("Issue fixture must be an array or an object with an issues array."); + const report = buildIssueHygieneReport(issues, asOf); + if (options.json_output) await writeFile(options.json_output, `${JSON.stringify(report, null, 2)}\n`); + process.stdout.write(formatIssueHygieneReport(report)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/ci/run-extended-validation.sh b/scripts/ci/run-extended-validation.sh index 88b16b0..fd07826 100755 --- a/scripts/ci/run-extended-validation.sh +++ b/scripts/ci/run-extended-validation.sh @@ -1,68 +1,4 @@ #!/usr/bin/env bash set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "$ROOT_DIR" - -require_tool() { - local tool="$1" - if ! command -v "$tool" >/dev/null 2>&1; then - echo "Missing required tool: $tool" >&2 - exit 1 - fi -} - -run_step() { - local name="$1" - shift - echo - echo "==> $name" - "$@" -} - -# Issue #40: the macOS runner does not guarantee Homebrew/pkg-config. -# Build OpenSSL through the crate's vendored feature instead, and fail early -# with a clear prerequisite message if the runner lacks source-build tools. -ensure_vendored_openssl_build_inputs() { - [[ "${OSTYPE:-}" == darwin* || "${APW_FORCE_OPENSSL_INPUT_CHECK:-}" == "1" ]] || return 0 - - local missing=() - for tool in cc make perl; do - if ! command -v "$tool" >/dev/null 2>&1; then - missing+=("$tool") - fi - done - - if ((${#missing[@]} > 0)); then - echo "Missing required tool(s) for vendored OpenSSL build: ${missing[*]}" >&2 - echo "Install Xcode Command Line Tools and Perl on the macOS runner." >&2 - echo "If intentionally using system OpenSSL, set OPENSSL_NO_VENDOR=1 and provide OPENSSL_DIR/PKG_CONFIG_PATH." >&2 - exit 1 - fi -} - -ensure_vendored_openssl_build_inputs - -if [[ "${1:-}" == "--check-build-inputs" ]]; then - echo "Vendored OpenSSL build inputs are available." - exit 0 -fi - -require_tool cargo -require_tool swift -require_tool xcodebuild - -run_step "Rust native app end-to-end tests" \ - cargo test --manifest-path rust/Cargo.toml --test native_app_e2e - -run_step "Rust security regression tests" \ - cargo test --manifest-path rust/Cargo.toml --test security_regressions - -run_step "Rust clippy" \ - cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings - -run_step "Native app xcodebuild, signing, and entitlement preflight" \ - bash scripts/ci/run-native-app-preflight.sh - -echo -echo "APW extended validation passed." +bash scripts/ci/run-fast-checks.sh +echo "No extended checks configured for the generic archetype yet." diff --git a/scripts/ci/run-fast-checks.sh b/scripts/ci/run-fast-checks.sh index 92f57ef..b0fe54e 100755 --- a/scripts/ci/run-fast-checks.sh +++ b/scripts/ci/run-fast-checks.sh @@ -1,57 +1,4 @@ #!/usr/bin/env bash set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "$ROOT_DIR" - -echo "Running APW fast checks..." - -if find . -path './.git' -prune -o -name '*.md' -print0 | xargs -0 grep -In '/Users/'; then - echo "Found machine-local absolute paths in markdown docs." >&2 - exit 1 -fi - -if [ ! -x scripts/bump-version.sh ]; then - echo "scripts/bump-version.sh must be executable." >&2 - exit 1 -fi - -if [ ! -x scripts/render-homebrew-formula.sh ]; then - echo "scripts/render-homebrew-formula.sh must be executable." >&2 - exit 1 -fi - -if grep -Eq '^[[:space:]]+security:[[:space:]]+true[[:space:]]*$' project.bootstrap.yaml && [ ! -f SECURITY.md ]; then - echo "project.bootstrap.yaml enables docs.security but SECURITY.md is missing." >&2 - exit 1 -fi - -chmod +x ./.github/scripts/verify-version-sync.sh -./.github/scripts/verify-version-sync.sh \ - rust/Cargo.toml \ - rust/src/cli.rs \ - rust/src/types.rs \ - packaging/homebrew/apw.rb \ - README.md \ - docs/INSTALLATION.md \ - docs/MIGRATION_AND_PARITY.md - -while IFS= read -r -d '' script; do - bash -n "$script" -done < <(find .github/scripts scripts -type f -name '*.sh' -print0) - -./scripts/test-render-homebrew-formula.sh -./scripts/test-render-native-app-info-plist.sh -./scripts/test-prepare-sparkle-appcast.sh -./scripts/ci/validate-appcast-contract.sh -./scripts/test-extended-validation-config.sh -./scripts/test-verify-universal-binaries.sh -./scripts/test-universal-release-config.sh -./scripts/test-notarize-native-app.sh -./scripts/test-quality-indicators.sh -./scripts/test-package-release-dmg.sh -./scripts/test-native-automation-config.sh -./scripts/test-native-app-preflight-config.sh -bash ./scripts/test-pr-fast-ci-config.sh - -echo "APW fast checks passed." +echo "Generic archetype selected." +echo "Add project-specific scripts and tighten scripts/ci/run-fast-checks.sh when the stack is finalized."