Add protected parallel delivery workflow (#6)

## Development lane / 开发通道

- Lane: `direct-to-main` bootstrap
- Base branch: `main`
- Active train: `n/a`
- Owning Agent/session: workflow coordinator

## Change / 改动

- Split authoritative CI into parallel static, contract, Rust lint/test
shards, Swift interaction, stress, overlay, bundle, and stable `Required
CI` jobs.
- Make every ready PR to `main` a complete gate while keeping task PRs
to `gd-ops/train/*` path-scoped.
- Add direct/train worktree coordination, bounded changelog fragments,
protected main/train ruleset automation, and safe same-repository squash
auto-merge.
- Let GitHub Release promote an exact successful main commit, reuse its
source proof, and create a missing release tag only after proof
validation.
- Keep local pre-push validation bounded to basic syntax, formatting,
contracts, and compilation; complete duplicate proof stays in GitHub CI.

This is the one bootstrap PR: the auto-merge workflow and required rules
do not exist on the base branch yet. After this PR passes and is merged
once, the exact `main` push must pass `Required CI`; then the
fail-closed applicator can activate both rulesets and all later ready
`gd-ops/*` PRs follow the automated path.

## Validation / 验证

- `actionlint .github/workflows/ci.yml .github/workflows/release.yml
.github/workflows/auto-merge.yml`
- `./script/validate_source_syntax.sh`
- `./script/validate_build_scripts_safety.sh --skip-source-syntax`
- Exact staged diff was reapplied to a clean detached worktree before
the final validation run.
- 28 validation-tooling tests, 40 release-distribution tests, macOS
build-contract tests, Release shell contracts, and test-isolation
validation passed.

## Risk / 风险

- No product runtime or credential access is added.
- `pull_request_target` never checks out or executes PR code and only
enables auto-merge after API verification of active PR and `Required CI`
rules.
- Full ruleset activation fails closed until the exact remote main
commit has a trusted successful GitHub Actions push run.
- Reversible repository settings are already enabled: auto-merge,
squash-only, update branch, and delete merged branches.

---------

Co-authored-by: gd-ops <gd-ops@xmfunny.com>
This commit is contained in:
xjxandgd-ops authored and GitHub committed 2026-08-11 20:28:02 +08:00
1 parent 4b7ff4a4d6
commit 790046d9a8
24 files changed
+3144 -225

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
## Development lane / 开发通道
- Lane / 通道: `direct-to-main` / `task-to-train` / `train-to-main`
- Base branch / 目标分支:
- Active train / 所属 train: `n/a` or `gd-ops/train/<name>`
- Owning Agent/session / 写入负责人:
## Scope and dependencies / 范围与依赖
- Owned paths / 独占修改路径:
- Shared files and coordinator / 共享文件及协调者:
- Depends on / 依赖 PR:
- Blocks / 阻塞 PR:
## Change / 改动
Describe the behavior and contract change. Task PRs to a train add one `changes/unreleased/*.json` fragment for each user-visible change. Direct or ready train PRs to `main` must have consumed all fragments into root `CHANGELOG.md`. / 描述行为与合同变化。进入 train 的任务 PR 应为每项用户可见变化添加一个 `changes/unreleased/*.json`;直接进入 `main` 或已 ready 的 train PR 必须将片段全部汇总进根 `CHANGELOG.md`。
## Validation / 验证
- Local fast gate:
- Focused checks:
- Environment-dependent gates skipped:
- Visible UI evidence, when applicable:
## Risk / 风险
- Privacy/security:
- Migration/compatibility:
- Performance/accessibility:
- Release/version impact:
+44
View File
@@ -0,0 +1,44 @@
name: Enable protected PR auto-merge
on:
pull_request_target:
types: [opened, reopened, ready_for_review]
permissions:
contents: read
jobs:
enable:
name: Enable squash auto-merge
if: >-
!github.event.pull_request.draft &&
github.event.pull_request.head.repo.full_name == github.repository &&
startsWith(github.head_ref, 'gd-ops/') &&
(github.base_ref == 'main' || startsWith(github.base_ref, 'gd-ops/train/'))
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: write
pull-requests: write
env:
GH_TOKEN: ${{ github.token }}
PR_URL: ${{ github.event.pull_request.html_url }}
BASE_BRANCH: ${{ github.base_ref }}
steps:
- name: Require active PR and Required CI rules before enabling auto-merge
run: |
encoded_base="$(jq -rn --arg value "$BASE_BRANCH" '$value|@uri')"
rules="$(
gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/rules/branches/$encoded_base"
)"
jq -e 'any(.[]; .type == "pull_request")' <<<"$rules" >/dev/null
jq -e '
any(.[].parameters.required_status_checks[]?;
.context == "Required CI")
' <<<"$rules" >/dev/null
- name: Enable squash auto-merge after required checks
run: gh pr merge --repo "$GITHUB_REPOSITORY" --auto --squash "$PR_URL"
+508 -29
View File
@@ -2,6 +2,7 @@ name: CI
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
push:
branches: [main]
@@ -12,16 +13,33 @@ concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
APC_VALIDATE_HOST_UI: "0"
APC_VALIDATE_REAL_AGENT_CONNECTORS: "0"
APC_VALIDATE_REAL_APP_SERVER: "0"
CARGO_TERM_COLOR: always
jobs:
quality:
name: Change-scoped quality and packaging
scope:
name: Classify change once
runs-on: macos-26
timeout-minutes: 45
env:
APC_VALIDATE_HOST_UI: "0"
APC_VALIDATE_REAL_AGENT_CONNECTORS: "0"
APC_VALIDATE_REAL_APP_SERVER: "0"
CARGO_TERM_COLOR: always
timeout-minutes: 10
outputs:
base: ${{ steps.validation_base.outputs.base }}
docs_only: ${{ steps.validation_scope.outputs.docs_only }}
localization: ${{ steps.validation_scope.outputs.localization }}
scripts: ${{ steps.validation_scope.outputs.scripts }}
producer: ${{ steps.validation_scope.outputs.producer }}
connectors: ${{ steps.validation_scope.outputs.connectors }}
schemas: ${{ steps.validation_scope.outputs.schemas }}
plugin_version: ${{ steps.validation_scope.outputs.plugin_version }}
rust_mode: ${{ steps.validation_scope.outputs.rust_mode }}
swift_mode: ${{ steps.validation_scope.outputs.swift_mode }}
bundle: ${{ steps.validation_scope.outputs.bundle }}
computer_use: ${{ steps.validation_scope.outputs.computer_use }}
lane: ${{ steps.development_context.outputs.lane }}
full_candidate: ${{ steps.development_context.outputs.full_candidate }}
release_source: ${{ steps.development_context.outputs.release_source }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -45,29 +63,98 @@ jobs:
git rev-parse --verify "$base^{commit}" >/dev/null
echo "base=$base" >>"$GITHUB_OUTPUT"
- name: Classify changed paths once
- name: Classify changed paths
id: validation_scope
run: |
./script/validation_scope.py \
--base-ref "${{ steps.validation_base.outputs.base }}" \
--format github >>"$GITHUB_OUTPUT"
- name: Resolve direct, train, and main validation lane
id: development_context
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_BRANCH: ${{ github.base_ref }}
PR_HEAD_BRANCH: ${{ github.head_ref }}
PR_DRAFT: ${{ github.event.pull_request.draft }}
run: |
if [[ "$EVENT_NAME" == "pull_request" ]]; then
./script/development_flow.py ci-context \
--event pull_request \
--base "$PR_BASE_BRANCH" \
--head "$PR_HEAD_BRANCH" \
--draft "${PR_DRAFT:-false}" \
--format github >>"$GITHUB_OUTPUT"
else
./script/development_flow.py ci-context \
--event push \
--base main \
--format github >>"$GITHUB_OUTPUT"
fi
static:
name: Static source contracts
needs: scope
runs-on: macos-26
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Install common validation tooling
run: |
if ! command -v rg >/dev/null 2>&1; then
brew install ripgrep
fi
rg --version
- name: Verify required toolchains
if: steps.validation_scope.outputs.bundle == '1' || steps.validation_scope.outputs.rust_mode != 'none' || steps.validation_scope.outputs.swift_mode != 'none'
- name: Diff and source syntax
run: |
./script/validate_macos_build_contract.py toolchain
xcrun --show-sdk-path
rustup show
git diff --check "${{ needs.scope.outputs.base }}" --
./script/validate_source_syntax.sh
- name: Localization parity
if: needs.scope.outputs.full_candidate == '1' || needs.scope.outputs.localization == '1'
run: ./script/validate_localizations.py
- name: Static workflow and build-script contracts
if: needs.scope.outputs.full_candidate == '1' || needs.scope.outputs.scripts == '1'
run: ./script/validate_build_scripts_safety.sh --skip-source-syntax --static-only
- name: Validate parallel changelog fragments
run: ./script/changelog_fragments.py validate
- name: Require main-bound changelog consolidation
if: >-
needs.scope.outputs.full_candidate == '1' &&
needs.scope.outputs.lane != 'task-to-train'
run: ./script/changelog_fragments.py require-consumed
- name: Codex plugin and Skill version discipline for this change
if: needs.scope.outputs.plugin_version == '1'
run: ./script/validate_codex_plugin_version.py --base-ref "${{ needs.scope.outputs.base }}"
contracts:
name: Source and integration contracts
needs: scope
if: >-
needs.scope.outputs.full_candidate == '1' ||
needs.scope.outputs.docs_only != '1'
runs-on: macos-26
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Install common validation tooling
run: |
if ! command -v rg >/dev/null 2>&1; then
brew install ripgrep
fi
- name: Prepare pinned Python validation environment
if: steps.validation_scope.outputs.producer == '1' || steps.validation_scope.outputs.bundle == '1'
if: needs.scope.outputs.full_candidate == '1' || needs.scope.outputs.producer == '1' || needs.scope.outputs.bundle == '1'
run: |
python3 -m venv "$RUNNER_TEMP/apc-validation-python"
"$RUNNER_TEMP/apc-validation-python/bin/python" -m pip install \
@@ -79,32 +166,424 @@ jobs:
PY
echo "$RUNNER_TEMP/apc-validation-python/bin" >>"$GITHUB_PATH"
- name: Cache Rust and Swift build products
if: steps.validation_scope.outputs.bundle == '1' || steps.validation_scope.outputs.rust_mode != 'none' || steps.validation_scope.outputs.swift_mode != 'none'
- name: Restore contract build products
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.cargo/registry
~/.cargo/git
target
apps/macos/.build
key: ${{ runner.os }}-${{ runner.arch }}-apc-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock', 'apps/macos/Package.swift', 'apps/macos/Package.resolved') }}
key: ${{ runner.os }}-${{ runner.arch }}-apc-contracts-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-apc-
${{ runner.os }}-${{ runner.arch }}-apc-contracts-
${{ runner.os }}-${{ runner.arch }}-apc-rust-
- name: Run unified change-scoped validation
- name: Complete build and workflow contracts
if: needs.scope.outputs.full_candidate == '1' || needs.scope.outputs.scripts == '1'
run: ./script/validate_build_scripts_safety.sh --skip-source-syntax
- name: App lifecycle contract
if: needs.scope.outputs.full_candidate == '1' || needs.scope.outputs.swift_mode != 'none'
run: ./script/validate_app_lifecycle_contract.sh
- name: Schema contracts
if: needs.scope.outputs.full_candidate == '1' || needs.scope.outputs.schemas == '1' || needs.scope.outputs.producer == '1'
run: |
./script/validate_pre_push.sh \
--base "${{ steps.validation_base.outputs.base }}" \
--ci
./script/validate_schema_fixtures.sh
./script/validate_petpack_spec_schemas.sh
- name: Build and fully validate development App when runtime inputs changed
if: steps.validation_scope.outputs.bundle == '1'
run: ./script/build_app_bundle.sh --configuration debug --validation full
- name: Portable producer contracts
if: needs.scope.outputs.full_candidate == '1' || needs.scope.outputs.producer == '1'
run: |
./script/validate_pet_skills.sh
./script/validate_portable_pet_maker.sh
- name: Generated connector runtime
if: needs.scope.outputs.full_candidate == '1' || needs.scope.outputs.connectors == '1'
run: ./script/validate_connectors_runtime.sh
- name: Security boundaries
if: >-
needs.scope.outputs.full_candidate == '1' ||
needs.scope.outputs.rust_mode != 'none' ||
needs.scope.outputs.schemas == '1' ||
needs.scope.outputs.producer == '1' ||
needs.scope.outputs.connectors == '1'
run: ./script/validate_security_boundaries.sh
rust_lint:
name: Rust formatting and linting
needs: scope
if: needs.scope.outputs.full_candidate == '1' || needs.scope.outputs.rust_mode != 'none'
runs-on: macos-26
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Verify Rust toolchain
run: rustup show
- name: Restore Rust lint cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-${{ runner.arch }}-apc-rust-lint-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-apc-rust-
- name: Rust formatting
run: cargo fmt --all -- --check
- name: Strict Rust linting
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
rust_tests:
name: Rust tests (${{ matrix.shard }})
needs: scope
if: needs.scope.outputs.full_candidate == '1' || needs.scope.outputs.rust_mode != 'none'
strategy:
fail-fast: false
matrix:
shard: [core, integration-a, integration-b, integration-c, integration-d]
runs-on: macos-26
timeout-minutes: 25
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Verify Rust toolchain
run: rustup show
- name: Restore Rust test cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-${{ runner.arch }}-apc-rust-${{ matrix.shard }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-apc-rust-${{ matrix.shard }}-
${{ runner.os }}-${{ runner.arch }}-apc-rust-
- name: Run inventory-checked Rust shard
run: ./script/validate_rust_test_shards.py --shard "${{ matrix.shard }}"
swift_interaction:
name: Swift and interaction proof
needs: scope
if: needs.scope.outputs.full_candidate == '1' || needs.scope.outputs.bundle == '1' || needs.scope.outputs.swift_mode != 'none'
runs-on: macos-26
timeout-minutes: 25
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Verify App build toolchain
run: ./script/validate_macos_build_contract.py toolchain
- name: Restore Swift build products
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: apps/macos/.build
key: ${{ runner.os }}-${{ runner.arch }}-apc-swift-${{ hashFiles('apps/macos/Package.swift', 'apps/macos/Package.resolved') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-apc-swift-
- name: Run complete Swift suite and create source interaction proof
env:
APC_BUILD_ID: source.${{ github.sha }}
run: |
./script/prepare_interaction_attestation.sh \
--output "$RUNNER_TEMP/interaction-attestation.json" \
--swift-scope all
- name: Upload source interaction proof
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-interaction-attestation-${{ github.sha }}
if-no-files-found: error
retention-days: 3
path: ${{ runner.temp }}/interaction-attestation.json
stress:
name: Bounded event-storm stress
needs: scope
if: needs.scope.outputs.full_candidate == '1'
runs-on: macos-26
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Restore Rust stress cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-${{ runner.arch }}-apc-rust-stress-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-apc-rust-
- name: Run release-grade bounded event storm
run: ./script/validate_event_storm.sh
overlay:
name: Offline overlay contracts
needs: [scope, swift_interaction]
if: >-
always() &&
(needs.scope.outputs.full_candidate == '1' || needs.scope.outputs.bundle == '1' || needs.scope.outputs.swift_mode != 'none') &&
needs.swift_interaction.result == 'success'
runs-on: macos-26
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ci-interaction-attestation-${{ github.sha }}
path: ${{ runner.temp }}/interaction-proof
- name: Restore Swift build products
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: apps/macos/.build
key: ${{ runner.os }}-${{ runner.arch }}-apc-swift-${{ hashFiles('apps/macos/Package.swift', 'apps/macos/Package.resolved') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-apc-swift-
- name: Run offline overlay geometry, scheduler, accessibility, frame, and pointer contracts
run: |
./script/validate_overlay_offline.sh \
--interaction-attestation "$RUNNER_TEMP/interaction-proof/interaction-attestation.json"
bundle:
name: Packaged development App proof
needs: [scope, contracts, rust_lint, rust_tests, swift_interaction, overlay]
if: >-
always() &&
(needs.scope.outputs.full_candidate == '1' || needs.scope.outputs.bundle == '1')
runs-on: macos-26
timeout-minutes: 30
steps:
- name: Require successful source dependencies
env:
CONTRACTS_RESULT: ${{ needs.contracts.result }}
RUST_LINT_RESULT: ${{ needs.rust_lint.result }}
RUST_TESTS_RESULT: ${{ needs.rust_tests.result }}
SWIFT_RESULT: ${{ needs.swift_interaction.result }}
OVERLAY_RESULT: ${{ needs.overlay.result }}
run: |
for result in "$CONTRACTS_RESULT" "$RUST_LINT_RESULT" "$RUST_TESTS_RESULT" "$SWIFT_RESULT" "$OVERLAY_RESULT"; do
case "$result" in
success|skipped) ;;
*) echo "bundle dependency did not succeed: $result" >&2; exit 1 ;;
esac
done
test "$SWIFT_RESULT" = "success"
test "$OVERLAY_RESULT" = "success"
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ci-interaction-attestation-${{ github.sha }}
path: ${{ runner.temp }}/interaction-proof
- name: Rebind source interaction proof to the development build identity
id: development_identity
run: |
version="$(awk -F'"' '/^version = / {print $2; exit}' crates/petcore/Cargo.toml)"
variant="debug|$(uname -m)|universal=0|signed=1"
fingerprint="$(
./script/validation_fingerprint.py \
--root "$GITHUB_WORKSPACE" \
--scope runtime \
--extra "$variant"
)"
build_id="$version.1.develop.${fingerprint:0:16}"
APC_BUILD_ID="$build_id" \
./script/prepare_interaction_attestation.sh \
--output "$RUNNER_TEMP/interaction-proof/development-attestation.json" \
--proof-in "$RUNNER_TEMP/interaction-proof/interaction-attestation.json"
echo "build_id=$build_id" >>"$GITHUB_OUTPUT"
- name: Prepare pinned Python validation environment
run: |
python3 -m venv "$RUNNER_TEMP/apc-validation-python"
"$RUNNER_TEMP/apc-validation-python/bin/python" -m pip install \
--disable-pip-version-check --no-deps --only-binary=:all: "Pillow==11.3.0"
echo "$RUNNER_TEMP/apc-validation-python/bin" >>"$GITHUB_PATH"
- name: Restore Rust build products
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-${{ runner.arch }}-apc-rust-bundle-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-apc-rust-
- name: Restore Swift build products
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: apps/macos/.build
key: ${{ runner.os }}-${{ runner.arch }}-apc-swift-${{ hashFiles('apps/macos/Package.swift', 'apps/macos/Package.resolved') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-apc-swift-
- name: Build and fully validate development App from the proven interaction contract
env:
APC_BUILD_ID: ${{ steps.development_identity.outputs.build_id }}
run: |
./script/build_app_bundle.sh \
--configuration debug \
--interaction-attestation "$RUNNER_TEMP/interaction-proof/development-attestation.json" \
--validation full
source_proof:
name: Bind release source proof to main commit
needs: [scope, static, contracts, rust_lint, rust_tests, swift_interaction, stress, overlay, bundle]
if: always() && needs.scope.outputs.release_source == '1'
runs-on: macos-26
timeout-minutes: 15
permissions:
contents: read
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Require every release source gate
env:
STATIC_RESULT: ${{ needs.static.result }}
CONTRACTS_RESULT: ${{ needs.contracts.result }}
RUST_LINT_RESULT: ${{ needs.rust_lint.result }}
RUST_TESTS_RESULT: ${{ needs.rust_tests.result }}
SWIFT_RESULT: ${{ needs.swift_interaction.result }}
STRESS_RESULT: ${{ needs.stress.result }}
OVERLAY_RESULT: ${{ needs.overlay.result }}
BUNDLE_RESULT: ${{ needs.bundle.result }}
run: |
for result in "$STATIC_RESULT" "$CONTRACTS_RESULT" "$RUST_LINT_RESULT" "$RUST_TESTS_RESULT" "$SWIFT_RESULT" "$STRESS_RESULT" "$OVERLAY_RESULT" "$BUNDLE_RESULT"; do
case "$result" in
success) ;;
*) echo "release source dependency did not succeed: $result" >&2; exit 1 ;;
esac
done
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ci-interaction-attestation-${{ github.sha }}
path: ${{ runner.temp }}/interaction-proof
- name: Verify and record proof-host toolchains
run: ./script/validate_macos_build_contract.py toolchain
- name: Resolve latest stable release baseline
id: release_baseline
run: |
previous_tag="$(
gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/latest" \
--jq '.tag_name'
)"
[[ "$previous_tag" =~ ^v[0-9]+[.][0-9]+[.][0-9]+$ ]]
echo "previous_tag=$previous_tag" >>"$GITHUB_OUTPUT"
- name: Recheck Codex plugin and bundled Skill version discipline
run: ./script/validate_codex_plugin_version.py --base-ref "${{ steps.release_baseline.outputs.previous_tag }}"
- name: Create exact-commit source proof
run: |
mkdir -p "$RUNNER_TEMP/release-source-proof"
cp "$RUNNER_TEMP/interaction-proof/interaction-attestation.json" \
"$RUNNER_TEMP/release-source-proof/interaction-attestation.json"
./script/release_source_proof.py create \
--repository "$GITHUB_REPOSITORY" \
--commit "$GITHUB_SHA" \
--previous-release-tag "${{ steps.release_baseline.outputs.previous_tag }}" \
--run-id "$GITHUB_RUN_ID" \
--run-attempt "$GITHUB_RUN_ATTEMPT" \
--attestation "$RUNNER_TEMP/release-source-proof/interaction-attestation.json" \
--output "$RUNNER_TEMP/release-source-proof/source-proof.json"
- name: Upload exact-commit release source proof
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-source-proof-${{ github.sha }}
if-no-files-found: error
retention-days: 7
path: ${{ runner.temp }}/release-source-proof
required:
name: Required CI
needs: [scope, static, contracts, rust_lint, rust_tests, swift_interaction, stress, overlay, bundle, source_proof]
if: always()
runs-on: macos-26
timeout-minutes: 10
steps:
- name: Require every scheduled job
env:
SCOPE_RESULT: ${{ needs.scope.result }}
STATIC_RESULT: ${{ needs.static.result }}
CONTRACTS_RESULT: ${{ needs.contracts.result }}
RUST_LINT_RESULT: ${{ needs.rust_lint.result }}
RUST_TESTS_RESULT: ${{ needs.rust_tests.result }}
SWIFT_RESULT: ${{ needs.swift_interaction.result }}
STRESS_RESULT: ${{ needs.stress.result }}
OVERLAY_RESULT: ${{ needs.overlay.result }}
BUNDLE_RESULT: ${{ needs.bundle.result }}
SOURCE_PROOF_RESULT: ${{ needs.source_proof.result }}
FULL_CANDIDATE: ${{ needs.scope.outputs.full_candidate }}
RELEASE_SOURCE: ${{ needs.scope.outputs.release_source }}
run: |
for result in \
"$SCOPE_RESULT" "$STATIC_RESULT" "$CONTRACTS_RESULT" \
"$RUST_LINT_RESULT" "$RUST_TESTS_RESULT" "$SWIFT_RESULT" \
"$STRESS_RESULT" "$OVERLAY_RESULT" "$BUNDLE_RESULT" "$SOURCE_PROOF_RESULT"; do
case "$result" in
success|skipped) ;;
*) echo "scheduled CI job did not succeed: $result" >&2; exit 1 ;;
esac
done
if [[ "$FULL_CANDIDATE" == "1" ]]; then
for result in \
"$STATIC_RESULT" "$CONTRACTS_RESULT" "$RUST_LINT_RESULT" \
"$RUST_TESTS_RESULT" "$SWIFT_RESULT" "$STRESS_RESULT" \
"$OVERLAY_RESULT" "$BUNDLE_RESULT"; do
case "$result" in
success) ;;
*) echo "complete main-bound job did not succeed: $result" >&2; exit 1 ;;
esac
done
fi
if [[ "$RELEASE_SOURCE" == "1" ]]; then
test "$SOURCE_PROOF_RESULT" = "success"
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Record live UI validation routing
env:
DEVELOPMENT_LANE: ${{ needs.scope.outputs.lane }}
run: |
if [[ "${{ steps.validation_scope.outputs.computer_use }}" == "recommended" ]]; then
printf 'Development lane: %s.\n' "$DEVELOPMENT_LANE" >>"$GITHUB_STEP_SUMMARY"
if [[ "${{ needs.scope.outputs.full_candidate }}" == "1" ]]; then
echo 'This is a complete main-bound candidate gate.' >>"$GITHUB_STEP_SUMMARY"
else
echo 'This is a change-scoped task-to-train or draft gate.' >>"$GITHUB_STEP_SUMMARY"
fi
if [[ "${{ needs.scope.outputs.computer_use }}" == "recommended" ]]; then
echo 'UI source changed: Computer Use is recommended for a human-owned acceptance session, but is never auto-run in CI.' >>"$GITHUB_STEP_SUMMARY"
else
echo 'No live UI or Computer Use validation is required for this change scope.' >>"$GITHUB_STEP_SUMMARY"
+110 -38
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch:
inputs:
tag:
description: Existing vX.Y.Z tag to publish
description: vX.Y.Z tag to create or reuse
required: true
type: string
build:
@@ -15,6 +15,10 @@ on:
required: true
default: "1"
type: string
commit:
description: Successful main commit to promote (blank uses current main)
required: false
type: string
permissions:
contents: read
@@ -25,10 +29,11 @@ concurrency:
jobs:
prepare:
name: Prove source once
name: Reuse exact main source proof
runs-on: macos-26
timeout-minutes: 75
timeout-minutes: 20
permissions:
actions: read
contents: read
outputs:
tag: ${{ steps.release_identity.outputs.tag }}
@@ -36,18 +41,18 @@ jobs:
build: ${{ steps.release_identity.outputs.build }}
commit: ${{ steps.release_identity.outputs.commit }}
previous_tag: ${{ steps.release_identity.outputs.previous_tag }}
tag_exists: ${{ steps.release_identity.outputs.tag_exists }}
env:
APC_VALIDATE_HOST_UI: "0"
APC_VALIDATE_REAL_AGENT_CONNECTORS: "0"
APC_VALIDATE_REAL_APP_SERVER: "0"
CARGO_TERM_COLOR: always
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
ref: ${{ github.event_name == 'workflow_dispatch' && (inputs.commit || 'main') || github.ref }}
- name: Resolve release identity
id: release_identity
@@ -69,9 +74,16 @@ jobs:
[[ "$build" =~ ^[1-9][0-9]*$ ]]
version="${tag#v}"
commit="$(git rev-parse HEAD)"
test "$(git rev-list -n 1 "$tag")" = "$commit"
git rev-parse --verify refs/remotes/origin/main >/dev/null
git merge-base --is-ancestor "$commit" refs/remotes/origin/main
tag_exists=0
if git rev-parse --verify "refs/tags/$tag^{commit}" >/dev/null 2>&1; then
test "$(git rev-list -n 1 "$tag")" = "$commit"
tag_exists=1
elif [[ "$EVENT_NAME" != "workflow_dispatch" ]]; then
echo "tag push did not resolve to a local tag" >&2
exit 1
fi
source_version="$(awk -F'"' '/^version = / {print $2; exit}' crates/petcore/Cargo.toml)"
test "$version" = "$source_version"
grep -q "^## \\[$version\\] - [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$" CHANGELOG.md
@@ -93,51 +105,77 @@ jobs:
echo "build=$build"
echo "commit=$commit"
echo "previous_tag=$previous_tag"
echo "tag_exists=$tag_exists"
} >>"$GITHUB_OUTPUT"
- name: Prepare source validation host
- name: Prepare source-proof verification host
run: |
test "$(uname -m)" = "arm64"
./script/validate_macos_build_contract.py toolchain
rustup show
if ! command -v rg >/dev/null 2>&1; then
brew install ripgrep
fi
- name: Prepare pinned Python validation environment
- name: Resolve successful trusted main CI run
id: source_proof_run
run: |
python3 -m venv "$RUNNER_TEMP/apc-validation-python"
"$RUNNER_TEMP/apc-validation-python/bin/python" -m pip install \
--disable-pip-version-check --no-deps --only-binary=:all: "Pillow==11.3.0"
"$RUNNER_TEMP/apc-validation-python/bin/python" - <<'PY'
from PIL import Image, features
if Image.__version__ != "11.3.0" or not features.check("webp_anim"):
raise SystemExit("pinned Pillow lacks the required animated WebP support")
PY
echo "$RUNNER_TEMP/apc-validation-python/bin" >>"$GITHUB_PATH"
runs_json="$RUNNER_TEMP/apc-ci-runs.json"
gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?branch=main&event=push&status=success&head_sha=${{ steps.release_identity.outputs.commit }}&per_page=100" \
>"$runs_json"
selection="$(
./script/resolve_release_source_proof.py run \
--runs-json "$runs_json" \
--repository "$GITHUB_REPOSITORY" \
--commit "${{ steps.release_identity.outputs.commit }}"
)"
run_id="$(jq -r '.run_id' <<<"$selection")"
run_attempt="$(jq -r '.run_attempt' <<<"$selection")"
[[ "$run_id" =~ ^[1-9][0-9]*$ ]]
[[ "$run_attempt" =~ ^[1-9][0-9]*$ ]]
- name: Restore source/build cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
artifacts_json="$RUNNER_TEMP/apc-ci-artifacts.json"
gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts?per_page=100" \
>"$artifacts_json"
artifact_name="release-source-proof-${{ steps.release_identity.outputs.commit }}"
./script/resolve_release_source_proof.py artifact \
--artifacts-json "$artifacts_json" \
--run-id "$run_id" \
--artifact-name "$artifact_name" >/dev/null
{
echo "run_id=$run_id"
echo "run_attempt=$run_attempt"
echo "artifact_name=$artifact_name"
} >>"$GITHUB_OUTPUT"
- name: Download exact-commit source proof from trusted CI run
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: |
~/.cargo/registry
~/.cargo/git
target
apps/macos/.build
key: macos-release-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock', 'apps/macos/Package.swift', 'apps/macos/Package.resolved') }}-${{ steps.release_identity.outputs.commit }}
restore-keys: |
macos-release-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock', 'apps/macos/Package.swift', 'apps/macos/Package.resolved') }}-
name: ${{ steps.source_proof_run.outputs.artifact_name }}
path: ${{ runner.temp }}/source-proof
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ steps.source_proof_run.outputs.run_id }}
- name: Run source, complete Swift interaction, integration, and stress gates
- name: Verify exact-commit source proof and rebind interaction attestation
env:
APC_BUILD_ID: ${{ steps.release_identity.outputs.version }}.${{ steps.release_identity.outputs.build }}.${{ steps.release_identity.outputs.commit }}
APC_INTERACTION_ATTESTATION_OUTPUT: ${{ runner.temp }}/release-interaction-attestation.json
run: |
defaults write NSGlobalDomain AppleLanguages -array "zh-Hans" "en"
test "$(defaults read NSGlobalDomain AppleLanguages | sed -n '2s/[ ,\"]//gp')" = "zh-Hans"
./script/test_all.sh --source-only --include-stress
./script/release_source_proof.py validate \
--repository "$GITHUB_REPOSITORY" \
--commit "${{ steps.release_identity.outputs.commit }}" \
--previous-release-tag "${{ steps.release_identity.outputs.previous_tag }}" \
--run-id "${{ steps.source_proof_run.outputs.run_id }}" \
--run-attempt "${{ steps.source_proof_run.outputs.run_attempt }}" \
--proof "$RUNNER_TEMP/source-proof/source-proof.json" \
--attestation "$RUNNER_TEMP/source-proof/interaction-attestation.json"
./script/prepare_interaction_attestation.sh \
--output "$RUNNER_TEMP/release-interaction-attestation.json" \
--proof-in "$RUNNER_TEMP/source-proof/interaction-attestation.json"
- name: Upload source-bound interaction proof
- name: Upload release-bound interaction proof
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-interaction-attestation
@@ -145,9 +183,41 @@ jobs:
retention-days: 3
path: ${{ runner.temp }}/release-interaction-attestation.json
ensure_tag:
name: Bind release tag to proven main commit
needs: prepare
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: write
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
RELEASE_COMMIT: ${{ needs.prepare.outputs.commit }}
TAG_EXISTS: ${{ needs.prepare.outputs.tag_exists }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 1
persist-credentials: false
ref: ${{ needs.prepare.outputs.commit }}
- name: Create missing lightweight tag after source proof succeeds
if: env.TAG_EXISTS != '1'
run: |
gh api --method POST \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/git/refs" \
-f "ref=refs/tags/$RELEASE_TAG" \
-f "sha=$RELEASE_COMMIT" >/dev/null
- name: Verify immutable release identity
run: ./script/verify_remote_release_tag.sh --tag "$RELEASE_TAG" --commit "$RELEASE_COMMIT"
build_archives:
name: Build ${{ matrix.architecture }} archive
needs: prepare
needs: [prepare, ensure_tag]
strategy:
fail-fast: false
matrix:
@@ -450,7 +520,9 @@ jobs:
printf '3. 从“应用程序”打开新版。\n\n'
printf 'Your pets, settings, history, and active work stay on this Mac and are preserved.\n\n'
printf '你的宠物、设置、历史和正在进行的工作会留在这台 Mac 上并保持不变。\n\n'
# shellcheck disable=SC2016
printf 'Apple silicon uses `macos-arm64`; Intel uses `macos-x86_64`. Download `SHA256SUMS.txt` with the ZIP and verify the selected archive before replacing the App.\n\n'
# shellcheck disable=SC2016
printf 'Apple 芯片使用 `macos-arm64`,Intel 使用 `macos-x86_64`。请同时下载 `SHA256SUMS.txt`,并在替换 App 前核对所选归档。\n\n'
printf '**First launch:** these Apps are ad-hoc signed, are not Developer ID signed, and are not Apple-notarized. If macOS blocks the App, Control-click it in Finder and choose **Open**, or use **System Settings → Privacy & Security → Open Anyway**. Only continue if you downloaded it from this GitHub Release and its SHA-256 matches the published checksum.\n\n'
printf '**首次打开:** 当前 App 使用 ad-hoc 签名,没有 Developer ID 签名,也未经过 Apple 公证。如果 macOS 阻止打开,请在 Finder 中按住 Control 点按 App 并选择“打开”,或前往“系统设置 → 隐私与安全性 → 仍要打开”。仅在文件来自本 GitHub Release 且 SHA-256 与校验和一致时继续。\n'
+3
View File
@@ -43,6 +43,9 @@ docs/
## Development Guidelines
- Keep changes scoped to the user's request, the product baseline, and the architecture already present in the repo.
- Treat local `main` as read-only. Choose either a direct PR to `main` for a hotfix/small isolated change or a task PR to the main Agent's shared `gd-ops/train/*` for parallel/cross-component work; the train then opens one final PR to `main`. Follow [parallel development](docs/development/parallel-development.md).
- Give every Agent/session an independent `gd-ops/task/*` or `gd-ops/fix/*` branch and worktree. One Agent owns each write path; sub-Agents hand work off by PR and never write directly into the train or another Agent's branch.
- The main Agent coordinates a shared train, shared schemas/manifests/version files, changelog-fragment consolidation, dependency order, and the final train PR. Train tasks record user-visible changes under `changes/unreleased/`; direct and final train PRs consume them into root `CHANGELOG.md`.
- Prefer typed schemas and structured parsers over ad hoc string parsing.
- Keep user-facing text bilingual when it belongs in public documentation or product onboarding.
- Record user-visible changes under `[Unreleased]` in root `CHANGELOG.md`; every GitHub Release, tag, and changelog version must match one-to-one.
+6
View File
@@ -14,6 +14,12 @@ Keep each user-visible change in its own bullet. Write the English text first, t
## [Unreleased]
### Changed / 变更
- Local pre-push validation is now a bounded formatting, syntax, contract, and compile gate, while required GitHub CI runs complete source checks in parallel across Rust shards, Swift interaction proof, integration contracts, overlay validation, stress, and App assembly. Agents choose either a direct hotfix/small PR to protected `main` or coordinated task PRs to one shared train followed by a final train PR; repository-owned ready PRs use squash auto-merge only after `Required CI`. A successful trusted `main` push emits an exact-commit source proof that GitHub Release reuses before dual-architecture packaging, and an explicit Release dispatch can bind a missing tag only after that proof succeeds. Fail-closed ruleset automation protects both `main` and integration trains after the new required check is proven remotely.
本地预推送验证现收敛为有界的格式、语法、合同与编译门禁;必选 GitHub CI 则通过 Rust 分片、Swift 交互证明、集成合同、悬浮层验证、压力与 App 组装并行完成全量源码检查。Agent 会在热修复/小范围 direct PR 与“多个任务 PR 汇入共享 train、再由 train PR 至 main”之间选择;仓库内 ready PR 仅在 `Required CI` 成功后 squash 自动合并。受信任的 `main` push 会生成绑定精确 commit 的源码证明,GitHub Release 在双架构打包前复用该证明,显式 Release dispatch 也只有在证明成功后才能绑定缺失 tag。fail-closed ruleset 自动化会在新必选检查已由远端证明后保护 `main` 与集成 train。
## [0.3.6] - 2026-08-11
### Changed / 变更
+12 -9
View File
@@ -12,22 +12,23 @@ Agent Pet Companion is a local-first native macOS project. Keep changes focused
## Workflow / 开发流程
1. Read [AGENTS.md](AGENTS.md), then inspect the implementation, schemas, manifests, tests, and the owning document listed in [docs/README.md](docs/README.md). / 先阅读 `AGENTS.md`,再检查相关实现、schema、manifest、测试和负责该主题的文档。
2. Use a focused branch (`xjx-` prefix for Codex work) and preserve unrelated worktree changes. / 使用聚焦分支;Codex 分支使用 `xjx-` 前缀,并保留工作区中无关改动。
3. Add the smallest useful regression test and update the owning contract when behavior changes. / 行为变化时补充最小有效回归测试,并更新对应契约。
4. Record user-visible changes under `[Unreleased]` in [CHANGELOG.md](CHANGELOG.md). / 用户可见变化写入 `CHANGELOG.md` 的 `[Unreleased]`。
2. Keep local `main` read-only. The Agent chooses a direct `gd-ops/task/*` or `gd-ops/fix/*` PR to `main` for hotfix/small isolated work, or joins the main Agent's shared `gd-ops/train/*` for parallel/cross-component work. / 本地 `main` 只读;热修复或小型独立任务直接 PR 至 `main`,并行或跨组件任务加入主 Agent 的共享 train。
3. Give each Agent/session an independent branch and worktree, preserve unrelated changes, and hand sub-Agent work to the train only through task PRs. / 每个 Agent/会话使用独立分支与 worktree,保留无关改动,并仅通过任务 PR 将子 Agent 工作交给 train。
4. Add the smallest useful regression test and update the owning contract when behavior changes. / 行为变化时补充最小有效回归测试,并更新对应契约。
5. Direct PRs update `[Unreleased]`; train task PRs add `changes/unreleased/*.json`, which the coordinator consumes before the final train PR becomes ready. / direct PR 更新 `[Unreleased]`;train 任务 PR 写入变更片段,最终 train PR ready 前由协调者汇总。
The complete branch, worktree, ownership, auto-merge, and coordinator commands are defined in [Parallel development](docs/development/parallel-development.md). / 完整分支、worktree、所有权、自动合并与协调命令见并行开发文档。
## Validation / 验证
The default gate is isolated and does not launch the GUI, modify user LaunchAgents, invoke real Agents, or read credentials:
The default local gate is intentionally fast and does not launch the GUI, modify user LaunchAgents, invoke real Agents, or read credentials:
```bash
APC_VALIDATE_HOST_UI=0 \
APC_VALIDATE_REAL_AGENT_CONNECTORS=0 \
APC_VALIDATE_REAL_APP_SERVER=0 \
./script/test_all.sh
./script/validate_pre_push.sh --plan-only
./script/validate_pre_push.sh
```
Run the smallest relevant check first, then the broader gate appropriate to the change. Real UI, connector, App Server, performance, and release checks are environment-dependent; report an unrun gate as skipped, never passed. [Validation profiles](docs/development/validation.md) define proof boundaries, and [macOS release](docs/release/macos-release.md) owns official distribution steps. / 先运行最小相关检查,再运行与改动相称的完整门禁。真实 UI、连接器、App Server、性能和发布检查依赖环境;未运行只能标记为 skipped。证明边界与正式发布步骤分别见对应文档。
GitHub CI is authoritative for complete Rust and Swift tests, simulated integrations, App assembly, stress, and release source proof. Use `test_all.sh --resume` locally only to reproduce or diagnose a remote failure. Real UI, connector, and App Server checks remain environment-dependent; report an unrun gate as skipped, never passed. [Validation profiles](docs/development/validation.md) define proof boundaries, and [macOS release](docs/release/macos-release.md) owns official distribution steps. / GitHub CI 权威负责全量 Rust/Swift 测试、模拟集成、App 组装、压力与发布源码证明;本地仅在复现或排查远端失败时使用 `test_all.sh --resume`。真实 UI、连接器和 App Server 检查仍依赖环境,未运行只能标记为 skipped。证明边界与正式发布步骤分别见对应文档。
Computer Use is recommended for live macOS UI work when available and useful, but the executing Agent may choose another suitable method. Launch verification builds through `script/build_and_run.sh`, scope host-affecting checks to the intended App or an owned validation runtime, and state what was directly observed. / Computer Use 在可用且适合任务时建议使用;执行 Agent 也可选择其他合适方法。验证构建通过 `script/build_and_run.sh` 启动,影响宿主的检查应限定在目标 App 或自有验证实例,并说明哪些行为经过直接观察。
@@ -35,4 +36,6 @@ Computer Use is recommended for live macOS UI work when available and useful, bu
Include: what changed, tests run, skipped environment gates, and any migration, privacy, performance, or accessibility impact. Add comparable before/after captures for visible UI changes. / 请说明改动、已运行测试、跳过的环境门禁,以及迁移、隐私、性能或无障碍影响;可见 UI 改动附同条件前后对比。
Ready PRs from repository-owned `gd-ops/*` branches enroll in squash auto-merge only after the base branch exposes active PR and `Required CI` rules. Every ready PR to `main` runs the complete gate; train task PRs run scoped CI, and the exact merged `main` commit produces the Release source proof. / 仓库内 `gd-ops/*` 的 ready PR 仅在目标分支已启用 PR 与 `Required CI` 规则后进入 squash 自动合并。所有面向 `main` 的 ready PR 均运行完整门禁;train 任务 PR 运行范围化 CI,精确的已合并 `main` commit 生成 Release 源码证明。
Do not commit build caches or output, `.env` files, credentials, generated jobs, exported diagnostics, `.petpack` files, or temporary pet assets. Plans, progress logs, audits, and command output belong in issues, commits, PRs, CI, or Release notes rather than durable documentation. / 不要提交构建缓存或产物、`.env`、凭据、生成任务、导出诊断、`.petpack` 或临时宠物素材。计划、进度、审计和命令输出不进入长期文档。
+13
View File
@@ -0,0 +1,13 @@
# Unreleased changelog fragments / 未发布变更片段
Train-based task PRs put one bounded JSON fragment here for every user-visible change. The train coordinator runs `./script/changelog_fragments.py consume --apply` before marking the train PR to `main` ready. Direct PRs to `main` update root `CHANGELOG.md` directly and must not leave a fragment. / 基于 train 的任务 PR 应为每项用户可见变化在此写入一个有界 JSON 片段;train 协调者在将面向 `main` 的 PR 标记为 ready 前运行 `./script/changelog_fragments.py consume --apply`。直接进入 `main` 的 PR 应直接更新根 `CHANGELOG.md`,不得遗留片段。
```json
{
"schema_version": "apc.changelog-fragment.v1",
"kind": "Changed",
"scope": "overlay",
"summary_en": "Desktop bubbles keep the newest Agent reply.",
"summary_zh": "桌面气泡保留最新的 Agent 回复。"
}
```
+31 -13
View File
@@ -142,15 +142,31 @@ fn assert_matches_petpack_metadata_schema(schema_name: &str, instance: &serde_js
}
fn write_fake_app_server_script(path: &Path, thread_id: &str) {
write_fake_app_server_script_with_marker(path, thread_id, None);
}
fn write_fake_app_server_script_with_marker(
path: &Path,
thread_id: &str,
process_file: Option<&Path>,
) {
// Keep process evidence bound to this fixture script. A process-global
// marker variable can be inherited by an older asynchronous test worker
// after the next test has already replaced the environment.
let process_marker = process_file.map_or_else(String::new, |process_file| {
let quoted_path = format!(
"'{}'",
process_file.display().to_string().replace('\'', "'\\''")
);
format!(
"app_server_pgid=\"$(ps -o pgid= -p \"$$\")\"\nset -- $app_server_pgid\nprintf '%s %s\\n' \"$$\" \"$1\" > {quoted_path}"
)
});
let mut file = std::fs::File::create(path).unwrap();
writeln!(
file,
r#"#!/bin/sh
if [ -n "${{APC_FAKE_APP_SERVER_PROCESS_FILE:-}}" ]; then
app_server_pgid="$(ps -o pgid= -p "$$")"
set -- $app_server_pgid
printf '%s %s\n' "$$" "$1" > "$APC_FAKE_APP_SERVER_PROCESS_FILE"
fi
{process_marker}
while IFS= read -r request; do
case "$request" in
*initialize*)
@@ -3701,12 +3717,12 @@ fn generation_fails_when_all_reference_images_are_unusable() {
let temp = tempfile::tempdir().unwrap();
let fake_app_server = temp.path().join("fake_app_server.sh");
let app_server_process_file = temp.path().join("app-server-must-not-start");
write_fake_app_server_script(&fake_app_server, "thread_fake_unusable_reference");
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", fake_app_server.as_os_str());
let _process_file = EnvVarGuard::set(
"APC_FAKE_APP_SERVER_PROCESS_FILE",
app_server_process_file.as_os_str(),
write_fake_app_server_script_with_marker(
&fake_app_server,
"thread_fake_unusable_reference",
Some(&app_server_process_file),
);
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", fake_app_server.as_os_str());
let paths = AppPaths::new(temp.path().to_path_buf());
let state = CoreState::new(paths);
state.ensure_ready().unwrap();
@@ -3900,11 +3916,13 @@ fn generation_reply_is_rejected_while_job_is_running() {
let fake_app_server = temp.path().join("fake_app_server.sh");
let wait_file = temp.path().join("allow-app-server-complete");
let process_file = temp.path().join("fake-app-server-process");
write_fake_app_server_script(&fake_app_server, "thread_fake_running_reply");
write_fake_app_server_script_with_marker(
&fake_app_server,
"thread_fake_running_reply",
Some(&process_file),
);
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", fake_app_server.as_os_str());
let _wait_file = EnvVarGuard::set("APC_FAKE_APP_SERVER_WAIT_FILE", wait_file.as_os_str());
let _process_file =
EnvVarGuard::set("APC_FAKE_APP_SERVER_PROCESS_FILE", process_file.as_os_str());
let paths = AppPaths::new(temp.path().to_path_buf());
let state = CoreState::new(paths);
state.ensure_ready().unwrap();
+1
View File
@@ -21,6 +21,7 @@ When prose disagrees with code, investigate the implementation and update the si
| [Agent connectors](integrations/agent-connectors.md) | Host adapters, event mapping, routing, managed operations, and privacy / Agent 适配、事件映射、路由、受管操作与隐私 |
| [`.petpack` V3](specifications/AgentPetCompanion_Petpack_Whitepaper_V3.md) | Portable package format and producer conformance / 可移植宠物包格式与制作合规要求 |
| [Validation profiles](development/validation.md) | What each validation layer proves / 各验证层能够证明什么 |
| [Parallel development](development/parallel-development.md) | Direct/train PR lanes, Agent ownership, auto-merge, and protected delivery / direct/train PR 通道、Agent 所有权、自动合并与受保护交付 |
| [macOS release](release/macos-release.md) | Official GitHub Release procedure and installation contract / 正式 GitHub Release 流程与安装合同 |
| [CHANGELOG](../CHANGELOG.md) | Versioned user-visible changes / 按版本记录的用户可见变化 |
+86
View File
@@ -0,0 +1,86 @@
# Parallel Development and Protected Delivery / 并行开发与受保护交付
`main` is read-only for local development. Every change is delivered by a pull request through one of two lanes; the executing Agent chooses the smallest safe lane from current scope and coordination needs. / 本地开发不得直接写入 `main`。所有变更通过以下两类 PR 交付;执行 Agent 根据当前范围与协调需求选择最小且安全的通道。
## Lane selection / 通道选择
| Situation / 场景 | Lane / 通道 | PR chain / PR 链路 |
|---|---|---|
| Hotfix or small isolated update / 热修复或小型独立更新 | Direct | `gd-ops/fix/*` or `gd-ops/task/*` → `main` |
| Multiple Agents/sessions, cross-component feature, or an active shared train / 多 Agent/会话、跨组件功能或已有共享 train | Train | task/fix branches → `gd-ops/train/*` → `main` |
`./script/development_flow.py decide` makes the same default decision mechanically. An explicit `--lane direct|train` is allowed when the Agent has stronger task evidence. Hotfix is the only automatic direct override while a train exists; ordinary sub-Agent work joins the main Agent's active train. / `decide` 命令可机械执行同一默认决策;Agent 有更充分证据时可显式指定通道。已有 train 时,只有 hotfix 会自动改走 direct,普通子 Agent 工作均加入主 Agent 的活动 train。
## Branch and worktree ownership / 分支与 worktree 所有权
- Branches use `gd-ops/train/<name>`, `gd-ops/task/<task>-<slug>`, or `gd-ops/fix/<task>-<slug>`. / 分支使用上述固定命名。
- Every Agent/session gets an independent branch and worktree. One Agent is the sole writer for each owned path at a time. / 每个 Agent/会话使用独立分支和 worktree;同一路径同一时刻只有一个写入者。
- The main Agent owns train creation, dependency order, shared schemas/manifests/version files, changelog consolidation, the final train PR, and conflict resolution. / 主 Agent 负责 train 创建、依赖顺序、共享 schema/manifest/版本文件、变更日志汇总、最终 train PR 与冲突处理。
- Sub-Agents never commit directly into another Agent's branch or the train branch. Their unit of handoff is a task PR. / 子 Agent 不直接写入其他 Agent 分支或 train 分支;任务 PR 是唯一交接单元。
The coordinator starts one shared train and task worktrees from explicit remote bases. Commands are plans unless `--apply` is present; shared state lives under Git's common directory so all worktrees see the same active train. / 协调者从明确的远端基线创建一个共享 train 和各任务 worktree。未给出 `--apply` 时命令只输出计划;共享状态位于 Git common directory,因此全部 worktree 能看到同一活动 train。
```bash
./script/development_flow.py train-start \
--name feature-set \
--worktree ../apc-train-feature-set
./script/development_flow.py train-start \
--name feature-set \
--worktree ../apc-train-feature-set \
--apply
./script/development_flow.py task-start \
--task 123 \
--slug pi-bubble \
--agents 3 \
--worktree ../apc-task-123-pi-bubble \
--apply
```
For direct work, pass `--hotfix` or `--small`; for an explicit train, pass `--lane train --train gd-ops/train/<name>`. The tool rejects missing train coordination, branch collisions, dirty PR worktrees, and unmanaged PR heads. / direct 工作使用 `--hotfix` 或 `--small`;显式 train 使用 `--lane train --train ...`。工具会拒绝缺少 train 协调、分支冲突、PR worktree 不干净及非受管 PR head。
## PR lifecycle and CI / PR 生命周期与 CI
Task PRs start as drafts unless the Agent explicitly marks them ready. A ready PR created by the helper is pushed, opened against its recorded base, and enrolled in squash auto-merge. The repository auto-merge workflow independently verifies that the base has active PR and `Required CI` rules before enabling auto-merge; it never checks out or executes pull-request code. / 任务 PR 默认以 draft 创建。Agent 显式标记 ready 后,辅助工具会推送、按记录基线创建 PR,并启用 squash auto-merge。仓库自动合并 workflow 会独立确认目标分支已启用 PR 与 `Required CI` 规则,且绝不 checkout 或执行 PR 代码。
```bash
./script/development_flow.py pr-open \
--worktree ../apc-task-123-pi-bubble \
--title "Optimize Pi session bubbles" \
--ready \
--apply
```
- Ready direct and train PRs to `main` run the complete source, integration, Rust, Swift, stress, bundle, and stable `Required CI` gate. They auto-merge only after every required result succeeds and the branch remains mergeable. / 所有 ready 的 direct/train → `main` PR 均运行完整门禁,仅在全部必选结果成功且仍可合并时自动合并。
- Task PRs to a train run path-scoped CI and auto-merge independently. Train status checks are intentionally non-strict so unrelated completed task PRs do not continuously invalidate each other. / task → train PR 按路径运行 CI 并独立自动合并;train 检查刻意使用 non-strict,避免无关任务互相持续失效。
- The post-merge `main` push reruns the release-grade set for the exact immutable main commit and emits its Release source proof. / 合并后的 `main` push 会针对精确且不可变的 main commit 再运行 Release 级门禁并生成发布源码证明。
## Changelog and final train integration / 变更日志与 train 收口
A train task PR adds one `changes/unreleased/*.json` fragment per user-visible change instead of editing the shared root changelog. Before the final train PR becomes ready, the coordinator consumes every fragment, resolves shared-file conflicts, and runs the local fast gate. Direct PRs update `[Unreleased]` directly and leave no fragments. / train 任务 PR 为每项用户可见变化添加一个 JSON 片段,不直接修改共享根变更日志。最终 train PR ready 前,协调者汇总所有片段、解决共享文件冲突并运行本地快速门禁;direct PR 直接更新 `[Unreleased]` 且不得遗留片段。
```bash
./script/changelog_fragments.py create \
--id 123-pi-bubble \
--kind Changed \
--scope pi \
--summary-en "Pi bubbles retain the newest Agent reply." \
--summary-zh "Pi 气泡保留最新的 Agent 回复。" \
--apply
./script/changelog_fragments.py consume --apply
./script/validate_pre_push.sh
```
After the train PR merges, the coordinator clears the active state with `train-clear --train ... --apply` and prunes only its owned worktrees. Branch deletion is performed by GitHub after merge. / train PR 合并后,协调者使用 `train-clear` 清除活动状态,并只清理自己拥有的 worktree;分支由 GitHub 在合并后删除。
## Protected branch activation / 受保护分支激活
Repository merge settings enable auto-merge, automatic merged-branch deletion, update-branch support, and squash-only history. The idempotent ruleset applicator protects `main` and `gd-ops/train/*`, but fails closed until the exact remote `main` commit has a successful trusted `Required CI` push run. / 仓库设置启用自动合并、合并后自动删分支、更新分支和仅 squash 历史。幂等 ruleset 配置器保护 `main` 与 `gd-ops/train/*`,但在远端精确 `main` commit 尚无受信任成功 `Required CI` push run 时会 fail closed。
```bash
./script/configure_main_branch_ruleset.py --settings-only --apply
./script/configure_main_branch_ruleset.py
./script/configure_main_branch_ruleset.py --apply
```
+24 -18
View File
@@ -6,7 +6,8 @@ Commands listed here define proof boundaries; they do not prove that a commit pa
| Profile / 层级 | Main entrypoints / 入口 | Proves / 能证明 | Does not prove / 不能证明 |
|---|---|---|---|
| Fast/core | Rust fmt/Clippy/tests, `validate_swift_tests.sh`, schema/security/overlay validators | Deterministic logic, schemas, Swift models, native overlay interaction contracts / 确定性逻辑、schema、Swift 模型与悬浮层交互契约 | Visible UI, real Agents, real App Server generation / 可见 UI、真实 Agent、真实 App Server 制作 |
| Local fast | `validate_pre_push.sh` | Diff hygiene, source syntax, lightweight contracts, formatting, and compilation of touched Rust/Swift inputs / diff、语法、轻量合同、格式与受影响 Rust/Swift 编译 | Complete tests, App assembly, visible UI, real Agents / 全量测试、App 组装、可见 UI 与真实 Agent |
| CI fast/core | parallel Rust lint/test shards, complete Swift interaction proof, schema/security/overlay validators | Deterministic logic, schemas, Swift models, native overlay interaction contracts, and packaged development App / 确定性逻辑、schema、Swift 模型、悬浮层交互合同与开发版 App | Visible UI, real Agents, real App Server generation / 可见 UI、真实 Agent、真实 App Server 制作 |
| Simulated integration | `validate_portable_pet_maker.sh`, `validate_connectors_runtime.sh` | Isolated package production, QA freshness, connector generation/normalization, library flows / 隔离宠物制作、QA 新鲜度、连接器与宠物库流程 | Artistic quality or a real provider task / 艺术质量或真实服务任务 |
| macOS runtime | `build_and_run.sh --verify`, bundle/overlay/window/renderer/recovery validators | The packaged App/runtime, clean-home bundled pets, persistence, rendering, and exercised recovery / 打包 App、运行时、内置宠物、持久化、渲染与恢复 | Unobserved UI behavior, real provider behavior, full profiling / 未直接观察的 UI、真实服务与完整性能结论 |
| Real connectors | `validate_real_agent_connectors.sh` | Current managed adapters can emit through the local runtime without reading credentials / 当前受管适配器可通过本地运行时发送事件 | Authentication, model execution, complete user task / 认证、模型执行、完整任务 |
@@ -16,44 +17,49 @@ Commands listed here define proof boundaries; they do not prove that a commit pa
Build and validation are separate profiles. `build_and_run.sh --run` and the environment Run button assemble, statically inspect, and launch a development App; they do not execute the packaged runtime acceptance suite first. Development build identity is a stable fingerprint of runtime inputs and build variant, so an unchanged second Run keeps Cargo/Swift incremental products instead of recompiling because of a timestamp-only ID. Use `build_and_run.sh --verify` for full packaged runtime proof. `build_app_bundle.sh --validation static|full` exposes the same boundary for automation. / 构建与验证是两个层级。日常 Run 只组装并静态检查开发 App 后启动;开发 build ID 绑定运行时源码与构建变体,源码不变的再次 Run 不会因时间戳变化重编译。完整打包运行时证明使用 `--verify`。
## Default gate / 默认门禁
```bash
APC_VALIDATE_HOST_UI=0 \
APC_VALIDATE_REAL_AGENT_CONNECTORS=0 \
APC_VALIDATE_REAL_APP_SERVER=0 \
./script/test_all.sh
```
The default gate uses isolated homes and must not launch the GUI, mutate user LaunchAgents, invoke real Agents, or read credentials. Bounded event-storm stress is opt-in with `--include-stress`; Release always enables it. Run a focused component check first when diagnosing a failure. / 默认门禁使用隔离 home,不启动 GUI、不修改用户 LaunchAgent、不调用真实 Agent,也不读取凭据。事件风暴压力测试通过 `--include-stress` 按需启用,Release 必须启用;排错时先运行最小相关检查。
For an ordinary local commit, start with the change-scoped pre-push gate. It always checks the diff and source syntax, then selects localization parity, Rust packages, Swift tests, connector smoke, or pet-production checks from the changed paths. `--plan-only` shows the selection without running it, and `--full` moves to the complete local gate. / 普通本地提交应先运行变更范围预推送门禁。它始终检查 diff 与源码语法,再根据变更路径选择本地化一致性、Rust package、Swift 测试、连接器 smoke 或宠物制作检查;`--plan-only` 仅显示计划,`--full` 转入完整本地门禁。
## Local default gate / 本地默认门禁
```bash
./script/validate_pre_push.sh --plan-only
./script/validate_pre_push.sh
```
The local default is deliberately bounded: it checks diff and source syntax, localization and lightweight Skill/workflow contracts when touched, Rust formatting plus `cargo check`, and `swift build`. It does not run complete Rust or Swift tests, simulated connector/producer roundtrips, App assembly, stress, or release artifact validation. Those duplicate proofs belong to required GitHub CI. The local gate never launches the GUI, mutates user LaunchAgents, invokes real Agents, or reads credentials. / 本地默认门禁刻意保持轻量:检查 diff 与源码语法,并按改动运行本地化、轻量 Skill/workflow 合同、Rust 格式与 `cargo check`、`swift build`;它不再重复全量 Rust/Swift 测试、连接器或制作 roundtrip、App 组装、压力与发布产物验证,这些由 GitHub 必选 CI 负责。本地门禁不会启动 GUI、修改用户 LaunchAgent、调用真实 Agent 或读取凭据。
`validation_scope.py` is the single path classifier used by local pre-push and CI. The routing contract is: / 本地预推送与 CI 共用 `validation_scope.py`,范围合同如下:
| Change / 变更 | Automatic proof / 自动证明 | Not automatic / 不自动执行 |
|---|---|---|
| Documentation only / 仅文档 | links, syntax, diff hygiene / 链接、语法、diff | Rust, Swift, App build, Computer Use |
| Localization resources / 本地化资源 | localization parity / 本地化一致性 | full Swift, App build, Computer Use |
| Overlay-only Swift / 仅悬浮层 Swift | seven focused overlay/frame suites plus CI bundle proof / 7 组聚焦测试与 CI bundle | unrelated Swift suites; live UI is only recommended |
| Other Swift / 其他 Swift | full Swift plus CI bundle proof / 全量 Swift 与 CI bundle | Computer Use unless visible behavior needs observation |
| Rust package / Rust package | affected package or workspace boundary, plus CI bundle when runtime inputs changed / 对应 package 或 workspace,运行时输入变化时再构建 bundle | unrelated component tests |
| Overlay-only Swift / 仅悬浮层 Swift | local compile; CI complete Swift interaction proof, offline overlay proof, and bundle / 本地编译;CI 全量 Swift 交互证明、离线悬浮层证明与 bundle | live UI is only recommended / 可见 UI 仅按需建议 |
| Other Swift / 其他 Swift | local compile; CI complete Swift plus bundle proof / 本地编译;CI 全量 Swift 与 bundle | Computer Use unless visible behavior needs observation |
| Rust package / Rust package | local format/compile; CI strict lint plus inventory-checked parallel core and integration shards / 本地格式与编译;CI 严格 lint 及带清单校验的并行核心/集成分片 | unrelated live UI / 无关可见 UI |
| Scripts/workflows / 脚本与 workflow | syntax and release-contract tests; bundle only for bundle-owning scripts / 语法与发布契约;仅 bundle 相关脚本触发 bundle | product UI acceptance |
| Release / 发布 | Swift 6.2+/SDK 26+ source gate, explicit stress, parallel thin builds, SDK 26 + macOS 14 deployment + weak-link inspection, native macOS 15 acceptance for both architectures, and packaged macOS 26 acceptance / Swift 6.2+/SDK 26+ 源码门禁、显式压力测试、双架构并行构建、SDK 26 + macOS 14 部署目标 + 弱链接检查、双架构 macOS 15 原生验收及 macOS 26 打包验收 | repeated package execution after trusted digest equality |
During local full-gate diagnosis, `test_all.sh --resume` stores successful step checkpoints under this worktree's Git directory, and every executed step reports its wall-clock duration. Every checkpoint includes a source-scope fingerprint, command, workload settings, and local toolchain identity; a relevant source or toolchain change reruns that step, while unrelated successful steps can be reused. Interaction evidence is content-bound to `interaction-contract-files.txt`; the same proven suites may be rebound to the current validation build and reused by offline overlay and App assembly only after the source digest is rechecked. App assembly itself always reruns because it produces the final bundle. Clear local checkpoints explicitly with `test_all.sh --clear-cache`. / 本地完整门禁排错时,`test_all.sh --resume` 会把成功步骤的检查点写入当前 worktree 的 Git 目录,每个实际执行的步骤也会报告墙钟耗时。每个检查点都绑定源码作用域指纹、命令、负载设置与本机工具链;相关源码或工具链变化会重跑该步骤,无关的已通过步骤可以复用。交互证据按 `interaction-contract-files.txt` 内容绑定;只有重新核对源码摘要后,已证明的同组测试才能绑定到当前验证 build,并供离线悬浮层与 App 组装复用。最终 App 组装始终重跑,因为它负责产出最终 bundle。可用 `test_all.sh --clear-cache` 显式清除本地检查点。
`test_all.sh --resume` remains an explicit local diagnosis tool, not a pre-push requirement. It stores successful step checkpoints under this worktree's Git directory and reports wall-clock duration. Use it only to reproduce a remote failure or investigate a complete gate locally. / `test_all.sh --resume` 仍可用于本地完整门禁排错,但不再是推送前要求;它会在当前 worktree 的 Git 目录保存成功步骤检查点并报告耗时,仅应在复现远端失败或本地排查完整门禁时使用。
```bash
./script/test_all.sh --resume
./script/test_all.sh --clear-cache
```
Authoritative CI never consumes local checkpoints. Ordinary CI runs `validate_pre_push.sh --ci` with the shared classifier and builds a fully validated App only when runtime inputs changed. Release runs `test_all.sh --source-only --include-stress`, emits one source-bound interaction proof, and validates the exact packaged artifacts separately. Its complete Swift run also produces the interaction proof, so the five interaction suites are not run a second time. The String Catalog/`.strings` parity check remains ahead of expensive suites. / 权威 CI 不消费本地检查点。普通 CI 按变更范围运行,并只在运行时输入变化时构建完整验证的 App;Release 使用 `--source-only --include-stress`,一次完整 Swift 运行同时生成交互证明,再单独验证精确发布产物,避免重复运行 5 组交互测试。
Authoritative CI never consumes local checkpoints or calls the local fast gate. One classifier fans work out into static contracts, integration contracts, Rust lint, five inventory-checked Rust test shards, complete Swift interaction proof, bounded stress, offline overlay proof, and App assembly. Ready direct or train PRs to `main` run that complete set before auto-merge; task PRs to `gd-ops/train/*` remain path-scoped. Every post-merge `main` push runs the complete release-grade set again for the exact immutable main commit. A stable `Required CI` job fails unless every scheduled dependency succeeded. / 权威 CI 不消费本地检查点,也不调用本地快速门禁。统一分类结果会并行路由到静态合同、集成合同、Rust lint、5 个带清单校验的 Rust 测试分片、完整 Swift 交互证明、压力、离线悬浮层证明与 App 组装。ready 的 direct/train → `main` PR 在自动合并前运行完整集合;task → train PR 保持按路径验证。合并后的每次 `main` push 会针对精确且不可变的 main commit 再运行完整 Release 级门禁。稳定的 `Required CI` job 仅在所有计划任务成功时通过。
After a successful `main` push, CI uploads exactly `source-proof.json` and `interaction-attestation.json` as `release-source-proof-FULL_COMMIT`. The proof binds repository, full commit and tree, latest stable baseline, trusted push run ID/attempt, gate inventory, toolchain-contract digest, observed toolchains, and interaction bytes. Release accepts only a successful `push` run of `.github/workflows/ci.yml` on `main` from the same repository and exact commit; missing, expired, duplicate, malformed, or mismatched proof fails closed. The Release rebinds the proven interaction contract to its final build ID without repeating the complete source suites. / `main` push 成功后,CI 会以 `release-source-proof-FULL_COMMIT` 上传且仅上传 `source-proof.json` 与 `interaction-attestation.json`。证明绑定仓库、完整 commit/tree、latest stable 基线、受信任 push run ID/attempt、门禁清单、工具链合同摘要、已观察工具链与交互证明字节。Release 只接受同仓库、同 commit、`main` 上成功的 `.github/workflows/ci.yml` push run;缺失、过期、重复、格式错误或不匹配均 fail closed,并只将已证明交互合同重新绑定到最终 build ID,不再重复全量源码测试。
Protected delivery is owned by the repository's idempotent ruleset applicator. `Protected default branch` requires pull requests, squash-only linear history, resolved conversations, current-base `Required CI`, and blocks force-push/deletion. `Protected integration trains` applies the PR, squash, and `Required CI` contract to `gd-ops/train/*`, but uses non-strict status checks and permits deletion after the final train PR. Repository settings enable auto-merge, update-branch support, automatic merged-branch deletion, and disable merge/rebase commits. The separate auto-merge workflow handles only same-repository ready `gd-ops/*` PRs, verifies the base's active rules through GitHub's API, and never checks out PR code. / 受保护交付由幂等 ruleset 配置器负责。默认分支要求 PR、仅 squash 的线性历史、讨论已解决、基于最新基线的 `Required CI`,并禁止强推与删除;train 规则对 `gd-ops/train/*` 要求 PR、squash 与 `Required CI`,但采用 non-strict 检查并允许最终合并后删除。仓库设置启用自动合并、更新分支与合并后自动删分支,并关闭 merge/rebase commit。自动合并 workflow 只处理同仓库 ready 的 `gd-ops/*` PR,会经 GitHub API 校验目标规则,且绝不 checkout PR 代码。
During the initial migration, reversible repository settings may be applied first. Push the workflows and wait for the new exact `main` `Required CI` to succeed before applying either ruleset. Full apply fails closed until that trusted check exists. / 首次迁移可先应用可逆仓库设置;必须先推送 workflow 并等待新的精确 `main` `Required CI` 成功,才能应用两个 ruleset。完整 apply 在该受信任检查存在前会 fail closed。
```bash
./script/configure_main_branch_ruleset.py --settings-only --apply
./script/configure_main_branch_ruleset.py
./script/configure_main_branch_ruleset.py --apply
```
Branch/worktree ownership and direct/train PR coordination are defined by [Parallel development](parallel-development.md). / 分支、worktree 所有权及 direct/train PR 协调见并行开发文档。
## Environment-dependent gates / 环境门禁
+28 -14
View File
@@ -36,7 +36,9 @@ rustup target add aarch64-apple-darwin x86_64-apple-darwin
No Apple account, certificate, private key, notarization profile, release Variable, or release Secret is used.
Run the host-safe source gate on the exact candidate commit:
Merge the exact candidate commit to protected `main` and wait for the post-merge required CI check. The main-bound PR has already passed the complete gate before auto-merge; the trusted `main` push binds the same release-grade inventory to the exact immutable main commit and uploads its source proof. A manual dispatch may create the release tag only after validating that proof. The one-time ruleset migration procedure is owned by [Validation profiles](../development/validation.md). / 将精确候选 commit 合并到受保护的 `main` 并等待合并后的必选 CI。面向 main 的 PR 已在自动合并前通过完整门禁;受信任的 `main` push 会将同一 Release 级清单绑定到精确且不可变的 main commit 并上传源码证明。手动 dispatch 只有在验证该证明后才能创建发布 tag。一次性 ruleset 迁移步骤由验证文档维护。
The complete local command remains available only for diagnosis; it is not a release prerequisite when the exact remote proof exists:
```bash
APC_VALIDATE_HOST_UI=0 \
@@ -47,7 +49,7 @@ APC_VALIDATE_REAL_APP_SERVER=0 \
Then run the environment-dependent connector, App Server, visible-UI, renderer, and profiling gates required by [Validation profiles](../development/validation.md). The executing Agent selects a suitable live-App inspection method. A gate not run is skipped, never passed. / 随后按发布范围运行环境门禁;可见 App 的验收方法由执行 Agent 选择,未运行的门禁只能标记为 skipped。
The complete Swift run generates a build-bound interaction attestation in the same invocation. Bundle validation requires the final App to contain and successfully consume the attestation for its own build ID. GitHub automation persists that proof once and supplies it to both architecture builds. / 完整 Swift 测试在同一次调用中生成交互证明;自动化只生成一次,并复用于两个架构构建。
The complete Swift CI shard generates a source-build interaction attestation. `source-proof.json` binds its bytes to the repository, full commit/tree, latest stable baseline, trusted `main` push run, complete gate inventory, and toolchain contract. Release downloads it only from the successful exact-commit CI run, validates it, and rebinds the interaction attestation to the final release build ID before supplying it to both architecture builds. / 完整 Swift CI 分片会生成源码 build 的交互证明;`source-proof.json` 将其字节绑定到仓库、完整 commit/tree、latest stable 基线、受信任 `main` push run、完整门禁清单与工具链合同。Release 只从成功的同 commit CI run 下载并校验,再将交互证明重新绑定到最终发布 build ID,供两个架构构建复用。
## Development artifacts / 开发产物
@@ -80,7 +82,7 @@ dist/AgentPetCompanion-X.Y.Z-SHA256SUMS.txt
`SHA256SUMS.txt` contains exactly the two ZIP entries. No signature or notarization sidecar belongs in the asset set.
The GitHub workflow instead runs `--arch arm64` and `--arch x86_64` in parallel. It adds the internal `--source-gate-proven` handoff only after the required source job succeeded and supplied its attestation, avoiding two repeated script-contract passes. Each command produces one source-proven ZIP component; neither component is publishable alone. The assemble job downloads both, creates the shared checksum, rejects any extra file, and emits the three trusted digests. / GitHub workflow 在源码任务成功并提供证明后,使用内部 `--source-gate-proven` 交接并行构建两个架构,避免重复两次脚本契约门禁;单件不可单独发布。
The GitHub workflow runs `--arch arm64` and `--arch x86_64` in parallel. It adds the internal `--source-gate-proven` handoff only after reusing and validating the successful exact-commit main proof. Each command produces one source-proven ZIP component; neither component is publishable alone. The assemble job downloads both, creates the shared checksum, rejects any extra file, and emits the three trusted digests. / GitHub workflow 仅在复用并校验成功的同 commit main 证明后,才使用内部 `--source-gate-proven` 交接并行构建两个架构;单件不可单独发布。
Validate a clean local or downloaded three-file directory with:
@@ -96,18 +98,30 @@ Validation rejects missing/extra assets, unsafe or malformed ZIPs, checksum/iden
## GitHub automation / GitHub 自动化
`.github/workflows/release.yml` runs from a protected `vX.Y.Z` tag or explicit dispatch of an existing tag. In order it:
`.github/workflows/release.yml` runs from a protected `vX.Y.Z` tag or an explicit dispatch that promotes a successful main commit. The fast path is: / `.github/workflows/release.yml` 可由受保护 `vX.Y.Z` tag 或显式 dispatch 晋级一个成功的 main commit。快速路径如下:
1. verifies tag, source version, changelog, full commit, and Codex plugin/Skill version discipline;
2. runs the host-safe source, complete Swift interaction, integration, and explicit stress gates once, then uploads the source-bound interaction proof;
3. restores dependency/build caches and builds `arm64` and `x86_64` ZIP components in parallel on macOS 26 from the proven commit and proof;
4. assembles the exact three-file candidate once and records trusted digests;
5. validates the exact SDK/deployment/weak-link contract in every App archive;
6. runs the matching ZIP on macOS 15 arm64 and Intel hosts to prove the compatibility path, then runs the arm64 ZIP again on macOS 26 to prove the packaged modern-system path;
7. creates a non-prerelease draft with bilingual installation and first-open guidance after exact-inventory and digest checks;
8. downloads the draft assets and verifies exact inventory plus byte-for-byte equality with all three trusted digests, without rerunning the already completed native package suites;
9. publishes it as latest stable only after all checks pass; and
10. verifies through GitHub's API that the tag Release and `/releases/latest` are the same public stable Release with the trusted assets and digests.
```bash
gh workflow run release.yml \
-f tag=vX.Y.Z \
-f build=BUILD_NUMBER \
-f commit=FULL_40_CHARACTER_MAIN_COMMIT
```
Omit `commit` to use current `main`. Dispatch verifies source version, changelog, ancestry, latest stable baseline, and the successful exact-commit main proof before it creates a missing lightweight tag through GitHub's API. An existing tag must already target the same commit. Tag creation cannot make an unproven commit releasable, and any later job rechecks the remote identity. / 省略 `commit` 时使用当前 `main`。dispatch 会先验证源码版本、CHANGELOG、祖先关系、latest stable 基线及同 commit 的成功 main 证明,再通过 GitHub API 创建缺失的轻量 tag;已有 tag 必须已指向同一 commit。创建 tag 不能让未经证明的 commit 进入发布,后续任务仍会复核远端身份。
In order the workflow:
1. verifies source version, changelog, full main commit, latest stable baseline, and Codex plugin/Skill version discipline;
2. resolves the successful trusted `main` push CI run for that exact commit, rejects PR/fork/failed or ambiguous runs and artifacts, validates the two-file source proof, then rebinds its interaction attestation to the final release build ID;
3. creates a missing tag only after proof validation, or verifies the existing tag, then rechecks its remote commit;
4. restores dependency/build caches and builds `arm64` and `x86_64` ZIP components in parallel on macOS 26 from the proven commit and rebound proof;
5. assembles the exact three-file candidate once and records trusted digests;
6. validates the exact SDK/deployment/weak-link contract in every App archive;
7. runs the matching ZIP on macOS 15 arm64 and Intel hosts to prove the compatibility path, then runs the arm64 ZIP again on macOS 26 to prove the packaged modern-system path;
8. creates a non-prerelease draft with bilingual installation and first-open guidance after exact-inventory and digest checks;
9. downloads the draft assets and verifies exact inventory plus byte-for-byte equality with all three trusted digests, without rerunning the already completed native package suites;
10. publishes it as latest stable only after all checks pass; and
11. verifies through GitHub's API that the tag Release and `/releases/latest` are the same public stable Release with the trusted assets and digests.
Native compatibility validation on both architectures and packaged macOS 26 validation are hard dependencies; cross-building or static symbol inspection is not a substitute for runtime acceptance. Existing Releases are never overwritten. / 双架构原生兼容验收与 macOS 26 打包产物验收都是硬门禁;交叉构建或静态符号检查不能替代运行时验收,已有 Release 不会被覆盖。
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""Create, validate, render, and consume parallel changelog fragments."""
from __future__ import annotations
import argparse
import json
import os
import pathlib
import re
import sys
import tempfile
from dataclasses import dataclass
from typing import Any, NoReturn
SCHEMA_VERSION = "apc.changelog-fragment.v1"
CATEGORIES = ("Added", "Changed", "Fixed", "Deprecated", "Removed", "Security")
FRAGMENT_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}[.]json")
SCOPE = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}")
HEADINGS = {
"Added": "### Added / 新增",
"Changed": "### Changed / 变更",
"Fixed": "### Fixed / 修复",
"Deprecated": "### Deprecated / 弃用",
"Removed": "### Removed / 移除",
"Security": "### Security / 安全",
}
def fail(message: str) -> NoReturn:
raise ValueError(message)
@dataclass(frozen=True)
class Fragment:
path: pathlib.Path
kind: str
scope: str
summary_en: str
summary_zh: str
def markdown(self) -> str:
return f"- {self.summary_en}\n\n {self.summary_zh}\n"
def bounded_sentence(value: Any, field: str) -> str:
if (
not isinstance(value, str)
or not value.strip()
or value != value.strip()
or "\n" in value
or len(value) > 1200
):
fail(f"fragment {field} must be one bounded non-empty paragraph")
return value
def parse_fragment(path: pathlib.Path) -> Fragment:
if FRAGMENT_NAME.fullmatch(path.name) is None:
fail(f"invalid changelog fragment filename: {path.name}")
metadata = path.lstat()
if not path.is_file() or path.is_symlink() or metadata.st_size > 16 * 1024:
fail(f"changelog fragment must be a bounded regular file: {path.name}")
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict) or set(payload) != {
"schema_version",
"kind",
"scope",
"summary_en",
"summary_zh",
}:
fail(f"changelog fragment field inventory is invalid: {path.name}")
if payload["schema_version"] != SCHEMA_VERSION:
fail(f"changelog fragment schema is unsupported: {path.name}")
if payload["kind"] not in CATEGORIES:
fail(f"changelog fragment kind is invalid: {path.name}")
if not isinstance(payload["scope"], str) or SCOPE.fullmatch(payload["scope"]) is None:
fail(f"changelog fragment scope is invalid: {path.name}")
return Fragment(
path=path,
kind=payload["kind"],
scope=payload["scope"],
summary_en=bounded_sentence(payload["summary_en"], "summary_en"),
summary_zh=bounded_sentence(payload["summary_zh"], "summary_zh"),
)
def fragments(root: pathlib.Path) -> list[Fragment]:
directory = root / "changes/unreleased"
if not directory.is_dir():
fail("changes/unreleased directory is missing")
return [parse_fragment(path) for path in sorted(directory.glob("*.json"))]
def render(items: list[Fragment]) -> str:
blocks = []
for kind in CATEGORIES:
group = [item for item in items if item.kind == kind]
if not group:
continue
body = "\n".join(item.markdown().rstrip() for item in group)
blocks.append(f"{HEADINGS[kind]}\n\n{body}")
return "\n\n".join(blocks) + ("\n" if blocks else "")
def insert_fragments(changelog: str, items: list[Fragment]) -> str:
unreleased = changelog.find("## [Unreleased]")
if unreleased < 0:
fail("CHANGELOG is missing [Unreleased]")
next_version = changelog.find("\n## [", unreleased + len("## [Unreleased]"))
if next_version < 0:
next_version = len(changelog)
section = changelog[unreleased:next_version]
for item in items:
if item.summary_en in section or item.summary_zh in section:
fail(f"fragment is already present in CHANGELOG: {item.path.name}")
grouped: dict[str, list[Fragment]] = {kind: [] for kind in CATEGORIES}
for item in items:
grouped[item.kind].append(item)
for kind in CATEGORIES:
group = grouped[kind]
if not group:
continue
heading = HEADINGS[kind]
heading_at = section.find(heading)
addition = "\n".join(item.markdown().rstrip() for item in group)
if heading_at < 0:
section = section.rstrip() + f"\n\n{heading}\n\n{addition}\n"
continue
body_start = heading_at + len(heading)
next_heading = section.find("\n### ", body_start)
body_end = len(section) if next_heading < 0 else next_heading
before = section[:body_end].rstrip()
after = section[body_end:]
section = before + f"\n\n{addition}\n" + after
return changelog[:unreleased] + section.rstrip() + "\n" + changelog[next_version:]
def atomic_write(path: pathlib.Path, content: str) -> None:
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
try:
os.fchmod(descriptor, 0o644)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path.cwd())
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("validate")
subparsers.add_parser("render")
subparsers.add_parser("require-consumed")
create = subparsers.add_parser("create")
create.add_argument("--id", required=True)
create.add_argument("--kind", choices=CATEGORIES, required=True)
create.add_argument("--scope", required=True)
create.add_argument("--summary-en", required=True)
create.add_argument("--summary-zh", required=True)
create.add_argument("--apply", action="store_true")
consume = subparsers.add_parser("consume")
consume.add_argument("--apply", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
root = args.root.resolve()
if args.command == "create":
if FRAGMENT_NAME.fullmatch(f"{args.id}.json") is None:
fail("fragment id is invalid")
bounded_sentence(args.summary_en, "summary_en")
bounded_sentence(args.summary_zh, "summary_zh")
if SCOPE.fullmatch(args.scope) is None:
fail("fragment scope is invalid")
payload = {
"schema_version": SCHEMA_VERSION,
"kind": args.kind,
"scope": args.scope,
"summary_en": args.summary_en,
"summary_zh": args.summary_zh,
}
target = root / "changes/unreleased" / f"{args.id}.json"
target.parent.mkdir(parents=True, exist_ok=True)
if not args.apply:
print(json.dumps({"apply": False, "path": str(target), "content": payload}, indent=2))
return 0
if target.exists():
fail(f"fragment already exists: {target.name}")
atomic_write(target, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
parse_fragment(target)
print(target)
return 0
items = fragments(root)
if args.command == "validate":
print(f"Validated {len(items)} changelog fragment(s)")
elif args.command == "render":
print(render(items), end="")
elif args.command == "require-consumed":
if items:
fail("main-bound changes must consume all changelog fragments: " + ", ".join(item.path.name for item in items))
elif args.command == "consume":
if not args.apply:
print(render(items), end="")
return 0
if not items:
print("Consumed 0 changelog fragment(s) into CHANGELOG.md")
return 0
changelog = root / "CHANGELOG.md"
updated = insert_fragments(changelog.read_text(encoding="utf-8"), items)
atomic_write(changelog, updated)
for item in items:
item.path.unlink()
print(f"Consumed {len(items)} changelog fragment(s) into CHANGELOG.md")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, TypeError, ValueError, json.JSONDecodeError) as error:
print(f"changelog-fragments: {error}", file=sys.stderr)
raise SystemExit(1)
+387
View File
@@ -0,0 +1,387 @@
#!/usr/bin/env python3
"""Install repository settings plus protected main and train rulesets.
The command is deliberately dry-run by default. Full ``--apply`` refuses to
mutate GitHub unless the current remote default-branch commit already has a
successful ``Required CI`` check produced by GitHub Actions. ``--settings-only``
is the explicit exception for reversible merge settings. This makes the initial
CI migration safe: push the workflow first, wait for it to pass, then protect
the branches without creating an impossible required-check dependency.
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from typing import Any, NoReturn
API_VERSION = "2026-03-10"
RULESET_NAME = "Protected default branch"
TRAIN_RULESET_NAME = "Protected integration trains"
REQUIRED_CHECK = "Required CI"
REQUIRED_WORKFLOW = ".github/workflows/ci.yml"
def fail(message: str) -> NoReturn:
raise ValueError(message)
def run(command: list[str], *, input_text: str | None = None) -> str:
result = subprocess.run(
command,
check=False,
text=True,
input=input_text,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode != 0:
detail = result.stderr.strip() or result.stdout.strip()
fail(f"command failed ({' '.join(command)}): {detail}")
return result.stdout
def gh_json(endpoint: str) -> Any:
return json.loads(
run(
[
"gh",
"api",
"-H",
"Accept: application/vnd.github+json",
"-H",
f"X-GitHub-Api-Version: {API_VERSION}",
endpoint,
]
)
)
def select_required_check(
payload: dict[str, Any], repository: str
) -> dict[str, int | str]:
candidates = []
details_pattern = re.compile(
rf"https://github[.]com/{re.escape(repository)}/actions/runs/([1-9][0-9]*)/job/[1-9][0-9]*"
)
for check in payload.get("check_runs", []):
if not isinstance(check, dict):
continue
app = check.get("app") or {}
details_match = details_pattern.fullmatch(str(check.get("details_url") or ""))
if (
check.get("name") == REQUIRED_CHECK
and check.get("status") == "completed"
and check.get("conclusion") == "success"
and app.get("slug") == "github-actions"
and isinstance(check.get("id"), int)
and check["id"] > 0
and isinstance(app.get("id"), int)
and app["id"] > 0
and details_match is not None
):
candidates.append((check, int(details_match.group(1))))
if not candidates:
fail(
f"remote default-branch commit has no successful {REQUIRED_CHECK!r} "
"check from GitHub Actions; push the workflow and wait for CI first"
)
candidates.sort(
key=lambda item: (item[0].get("completed_at") or "", item[0].get("id") or 0)
)
selected, workflow_run_id = candidates[-1]
return {
"check_run_id": int(selected["id"]),
"integration_id": int(selected["app"]["id"]),
"workflow_run_id": workflow_run_id,
"completed_at": str(selected.get("completed_at") or ""),
}
def validate_workflow_run(
payload: dict[str, Any], repository: str, default_branch: str, commit: str
) -> None:
head_repository = payload.get("head_repository") or {}
if not (
payload.get("path") == REQUIRED_WORKFLOW
and payload.get("event") == "push"
and payload.get("head_branch") == default_branch
and payload.get("head_sha") == commit
and payload.get("status") == "completed"
and payload.get("conclusion") == "success"
and head_repository.get("full_name") == repository
):
fail(
f"{REQUIRED_CHECK!r} does not belong to a successful trusted "
f"{REQUIRED_WORKFLOW} push for the exact default-branch commit"
)
def pull_request_rule() -> dict[str, Any]:
return {
"type": "pull_request",
"parameters": {
"allowed_merge_methods": ["squash"],
"dismiss_stale_reviews_on_push": False,
"require_code_owner_review": False,
"require_last_push_approval": False,
"required_approving_review_count": 0,
"required_review_thread_resolution": True,
},
}
def status_rule(integration_id: int, *, strict: bool) -> dict[str, Any]:
return {
"type": "required_status_checks",
"parameters": {
"do_not_enforce_on_create": True,
"required_status_checks": [
{
"context": REQUIRED_CHECK,
"integration_id": integration_id,
}
],
"strict_required_status_checks_policy": strict,
},
}
def ruleset_payload(integration_id: int, *, train: bool = False) -> dict[str, Any]:
if integration_id <= 0:
fail("GitHub Actions integration ID must be positive")
rules = [
{"type": "non_fast_forward"},
{"type": "required_linear_history"},
pull_request_rule(),
status_rule(integration_id, strict=not train),
]
if not train:
rules.insert(0, {"type": "deletion"})
return {
"name": TRAIN_RULESET_NAME if train else RULESET_NAME,
"target": "branch",
"enforcement": "active",
"bypass_actors": [],
"conditions": {
"ref_name": {
"include": ["refs/heads/gd-ops/train/*"] if train else ["~DEFAULT_BRANCH"],
"exclude": [],
}
},
# Trains are intentionally deletable after their final PR merges. Main
# alone carries the deletion prohibition.
"rules": rules,
}
def repository_settings_payload() -> dict[str, Any]:
return {
"allow_auto_merge": True,
"delete_branch_on_merge": True,
"allow_merge_commit": False,
"allow_rebase_merge": False,
"allow_squash_merge": True,
"allow_update_branch": True,
"squash_merge_commit_title": "PR_TITLE",
"squash_merge_commit_message": "PR_BODY",
}
def existing_ruleset_id(payload: list[dict[str, Any]], name: str = RULESET_NAME) -> int | None:
matches = [
item
for item in payload
if item.get("name") == name and item.get("target") == "branch"
]
if len(matches) > 1:
fail(f"multiple repository rulesets are named {name!r}")
if not matches:
return None
ruleset_id = matches[0].get("id")
if not isinstance(ruleset_id, int) or ruleset_id <= 0:
fail(f"existing {name!r} ruleset has an invalid ID")
return ruleset_id
def mutate_ruleset(repository: str, ruleset_id: int | None, payload: dict[str, Any]) -> Any:
endpoint = f"repos/{repository}/rulesets"
method = "POST"
if ruleset_id is not None:
endpoint = f"{endpoint}/{ruleset_id}"
method = "PUT"
return json.loads(
run(
[
"gh",
"api",
"--method",
method,
"-H",
"Accept: application/vnd.github+json",
"-H",
f"X-GitHub-Api-Version: {API_VERSION}",
endpoint,
"--input",
"-",
],
input_text=json.dumps(payload, sort_keys=True),
)
)
def mutate_repository_settings(repository: str, payload: dict[str, Any]) -> Any:
return json.loads(
run(
[
"gh",
"api",
"--method",
"PATCH",
"-H",
"Accept: application/vnd.github+json",
"-H",
f"X-GitHub-Api-Version: {API_VERSION}",
f"repos/{repository}",
"--input",
"-",
],
input_text=json.dumps(payload, sort_keys=True),
)
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Plan or apply the Required CI ruleset for the GitHub default branch."
)
parser.add_argument(
"--repository",
help="GitHub OWNER/REPO; defaults to the repository selected by gh",
)
parser.add_argument(
"--apply",
action="store_true",
help="create or update the ruleset after verifying remote Required CI",
)
parser.add_argument(
"--settings-only",
action="store_true",
help="configure reversible repository merge settings without installing rulesets",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
repository = args.repository or json.loads(
run(["gh", "repo", "view", "--json", "nameWithOwner"])
)["nameWithOwner"]
if not isinstance(repository, str) or repository.count("/") != 1:
fail("repository must be OWNER/REPO")
repository_data = gh_json(f"repos/{repository}")
default_branch = repository_data.get("default_branch")
if not isinstance(default_branch, str) or not default_branch:
fail("repository has no default branch")
settings = repository_settings_payload()
if args.settings_only:
plan = {
"apply": args.apply,
"repository": repository,
"repository_settings": settings,
}
if not args.apply:
print(json.dumps(plan, indent=2, sort_keys=True))
return 0
response = mutate_repository_settings(repository, settings)
print(
json.dumps(
{key: response.get(key) for key in settings},
indent=2,
sort_keys=True,
)
)
return 0
branch_data = gh_json(f"repos/{repository}/branches/{default_branch}")
commit = (branch_data.get("commit") or {}).get("sha")
if not isinstance(commit, str) or len(commit) != 40:
fail("default branch did not resolve to a full commit")
check = select_required_check(
gh_json(f"repos/{repository}/commits/{commit}/check-runs?filter=latest&per_page=100"),
repository,
)
validate_workflow_run(
gh_json(f"repos/{repository}/actions/runs/{check['workflow_run_id']}"),
repository,
default_branch,
commit,
)
main_payload = ruleset_payload(int(check["integration_id"]))
train_payload = ruleset_payload(int(check["integration_id"]), train=True)
rulesets = gh_json(f"repos/{repository}/rulesets?includes_parents=false&targets=branch")
main_ruleset_id = existing_ruleset_id(rulesets, RULESET_NAME)
train_ruleset_id = existing_ruleset_id(rulesets, TRAIN_RULESET_NAME)
plan = {
"apply": args.apply,
"repository": repository,
"default_branch": default_branch,
"commit": commit,
"required_check": check,
"repository_settings": settings,
"rulesets": [
{
"action": "update" if main_ruleset_id is not None else "create",
"ruleset_id": main_ruleset_id,
"payload": main_payload,
},
{
"action": "update" if train_ruleset_id is not None else "create",
"ruleset_id": train_ruleset_id,
"payload": train_payload,
},
],
}
if not args.apply:
print(json.dumps(plan, indent=2, sort_keys=True))
return 0
mutate_repository_settings(repository, settings)
main_response = mutate_ruleset(repository, main_ruleset_id, main_payload)
train_response = mutate_ruleset(repository, train_ruleset_id, train_payload)
print(
json.dumps(
{
"repository": repository,
"commit": commit,
"rulesets": [
{
"ruleset_id": main_response.get("id"),
"name": main_response.get("name"),
"enforcement": main_response.get("enforcement"),
},
{
"ruleset_id": train_response.get("id"),
"name": train_response.get("name"),
"enforcement": train_response.get("enforcement"),
},
],
},
indent=2,
sort_keys=True,
)
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
print(f"configure-main-branch-ruleset: {error}", file=sys.stderr)
raise SystemExit(1)
+531
View File
@@ -0,0 +1,531 @@
#!/usr/bin/env python3
"""Coordinate direct and train-based multi-session development safely.
State is stored under Git's common directory so every worktree created from the
same checkout observes the same active train. Mutating commands are dry-run by
default and require ``--apply``.
"""
from __future__ import annotations
import argparse
import fcntl
import json
import os
import pathlib
import re
import subprocess
import sys
import tempfile
from dataclasses import asdict, dataclass
from typing import Any, NoReturn
SCHEMA_VERSION = "apc.development-flow.v1"
MAIN_BRANCH = "main"
TRAIN_PREFIX = "gd-ops/train/"
TASK_PREFIX = "gd-ops/task/"
FIX_PREFIX = "gd-ops/fix/"
TRAIN_PATTERN = re.compile(r"gd-ops/train/[a-z0-9][a-z0-9._-]{0,63}")
WORK_PATTERN = re.compile(r"gd-ops/(?:task|fix)/[A-Za-z0-9._-]+-[a-z0-9][a-z0-9._-]{0,63}")
TOKEN_PATTERN = re.compile(r"[A-Za-z0-9._-]+")
SLUG_PATTERN = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}")
def fail(message: str) -> NoReturn:
raise ValueError(message)
def run(
command: list[str],
*,
cwd: pathlib.Path,
check: bool = True,
input_text: str | None = None,
) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
command,
cwd=cwd,
check=False,
text=True,
input=input_text,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if check and result.returncode != 0:
detail = result.stderr.strip() or result.stdout.strip()
fail(f"command failed ({' '.join(command)}): {detail}")
return result
def repository_root(root: pathlib.Path) -> pathlib.Path:
value = run(
["git", "rev-parse", "--show-toplevel"], cwd=root.resolve()
).stdout.strip()
resolved = pathlib.Path(value).resolve()
if not resolved.is_dir():
fail("Git repository root is unavailable")
return resolved
def common_state_paths(root: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path]:
common = run(
["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
cwd=root,
).stdout.strip()
common_path = pathlib.Path(common)
if not common_path.is_absolute():
common_path = (root / common_path).resolve()
state_dir = common_path / "apc-development"
return state_dir / "state.json", state_dir / "state.lock"
def empty_state() -> dict[str, Any]:
return {
"schema_version": SCHEMA_VERSION,
"active_train": None,
"branch_bases": {},
}
def validate_state(payload: dict[str, Any]) -> dict[str, Any]:
if set(payload) != {"schema_version", "active_train", "branch_bases"}:
fail("development-flow state field inventory is invalid")
if payload.get("schema_version") != SCHEMA_VERSION:
fail("development-flow state schema is unsupported")
active_train = payload.get("active_train")
if active_train is not None and TRAIN_PATTERN.fullmatch(active_train) is None:
fail("development-flow active train is invalid")
branch_bases = payload.get("branch_bases")
if not isinstance(branch_bases, dict) or len(branch_bases) > 256:
fail("development-flow branch-base inventory is invalid")
for branch, base in branch_bases.items():
if (
not isinstance(branch, str)
or not isinstance(base, str)
or (WORK_PATTERN.fullmatch(branch) is None and TRAIN_PATTERN.fullmatch(branch) is None)
or (base != MAIN_BRANCH and TRAIN_PATTERN.fullmatch(base) is None)
):
fail("development-flow branch-base entry is invalid")
return payload
def read_state(path: pathlib.Path) -> dict[str, Any]:
if not path.exists():
return empty_state()
metadata = path.lstat()
if not path.is_file() or path.is_symlink() or metadata.st_size > 256 * 1024:
fail("development-flow state must be a bounded regular file")
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
fail("development-flow state must contain one JSON object")
return validate_state(payload)
def update_state(root: pathlib.Path, transform: Any) -> dict[str, Any]:
state_path, lock_path = common_state_paths(root)
state_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a+", encoding="utf-8") as lock:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
state = transform(read_state(state_path))
validate_state(state)
descriptor, temporary = tempfile.mkstemp(
prefix=".state.", dir=state_path.parent
)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(state, handle, sort_keys=True, separators=(",", ":"))
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, state_path)
finally:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
return state
@dataclass(frozen=True)
class LaneDecision:
lane: str
base_branch: str | None
reason: str
active_train: str | None
def choose_lane(
requested: str,
*,
hotfix: bool,
small: bool,
agents: int,
cross_component: bool,
active_train: str | None,
explicit_train: str | None = None,
) -> LaneDecision:
if agents <= 0:
fail("agent count must be positive")
train = explicit_train or active_train
if train is not None and TRAIN_PATTERN.fullmatch(train) is None:
fail("train branch must use gd-ops/train/<name>")
if requested == "direct":
return LaneDecision("direct", MAIN_BRANCH, "agent explicitly selected direct", train)
if requested == "train":
if train is None:
fail("train lane requires --train or one active shared train")
return LaneDecision("train", train, "agent explicitly selected train", train)
if requested != "auto":
fail("lane must be auto, direct, or train")
if hotfix:
return LaneDecision("direct", MAIN_BRANCH, "hotfix uses the direct lane", train)
if train is not None:
return LaneDecision("train", train, "shared active train is available", train)
if agents > 1 or cross_component:
return LaneDecision(
"train",
None,
"parallel or cross-component work requires the main agent to start a train",
None,
)
if small:
return LaneDecision("direct", MAIN_BRANCH, "small isolated change", None)
return LaneDecision(
"direct",
MAIN_BRANCH,
"single-agent isolated work defaults to direct; the agent may choose train",
None,
)
@dataclass(frozen=True)
class CIContext:
lane: str
full_candidate: bool
release_source: bool
def ci_context(event: str, base: str, head: str, draft: bool) -> CIContext:
if event == "push":
if base != MAIN_BRANCH:
fail("authoritative push CI is restricted to main")
return CIContext("main-push", True, True)
if event != "pull_request":
fail("CI development lane supports only pull_request and push")
if base == MAIN_BRANCH:
lane = "train-to-main" if TRAIN_PATTERN.fullmatch(head) else "direct-to-main"
return CIContext(lane, not draft, False)
if TRAIN_PATTERN.fullmatch(base):
if WORK_PATTERN.fullmatch(head) is None:
fail("a train accepts only gd-ops/task/* or gd-ops/fix/* pull requests")
return CIContext("task-to-train", False, False)
fail("pull request base must be main or gd-ops/train/<name>")
def ensure_new_branch(root: pathlib.Path, branch: str, worktree: pathlib.Path) -> None:
if worktree.exists():
fail(f"worktree path already exists: {worktree}")
local = run(
["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
cwd=root,
check=False,
)
if local.returncode == 0:
fail(f"local branch already exists: {branch}")
remote = run(
["git", "ls-remote", "--exit-code", "--heads", "origin", branch],
cwd=root,
check=False,
)
if remote.returncode == 0:
fail(f"remote branch already exists: {branch}")
if remote.returncode not in (0, 2):
fail(f"could not inspect remote branch {branch}: {remote.stderr.strip()}")
def command_plan(commands: list[list[str]], **metadata: Any) -> None:
print(
json.dumps(
{"apply": False, "commands": commands, **metadata},
indent=2,
sort_keys=True,
)
)
def repository_name(root: pathlib.Path) -> str:
payload = json.loads(
run(["gh", "repo", "view", "--json", "nameWithOwner"], cwd=root).stdout
)
value = payload.get("nameWithOwner")
if not isinstance(value, str) or value.count("/") != 1:
fail("GitHub repository identity is unavailable")
return value
def train_start(args: argparse.Namespace, root: pathlib.Path) -> None:
if SLUG_PATTERN.fullmatch(args.name) is None:
fail("train name must be a lowercase slug")
branch = f"{TRAIN_PREFIX}{args.name}"
worktree = args.worktree.resolve()
state_path, _ = common_state_paths(root)
current_state = read_state(state_path)
if current_state["active_train"] not in (None, branch):
fail(f"another train is already active: {current_state['active_train']}")
ensure_new_branch(root, branch, worktree)
commands: list[list[str]] = [
["git", "fetch", "origin", MAIN_BRANCH],
[
"git",
"worktree",
"add",
"-b",
branch,
str(worktree),
f"refs/remotes/origin/{MAIN_BRANCH}",
],
["git", "-C", str(worktree), "push", "-u", "origin", branch],
]
if not args.apply:
command_plan(commands, lane="train", branch=branch, base=MAIN_BRANCH)
return
for command in commands:
run(command, cwd=root)
def transform(state: dict[str, Any]) -> dict[str, Any]:
if state["active_train"] not in (None, branch):
fail(f"another train is already active: {state['active_train']}")
state["active_train"] = branch
state["branch_bases"][branch] = MAIN_BRANCH
return state
state = update_state(root, transform)
print(json.dumps(state, indent=2, sort_keys=True))
def task_start(args: argparse.Namespace, root: pathlib.Path) -> None:
if TOKEN_PATTERN.fullmatch(args.task) is None:
fail("task identifier is invalid")
if SLUG_PATTERN.fullmatch(args.slug) is None:
fail("task slug must be lowercase")
state_path, _ = common_state_paths(root)
state = read_state(state_path)
decision = choose_lane(
args.lane,
hotfix=args.hotfix,
small=args.small,
agents=args.agents,
cross_component=args.cross_component,
active_train=state["active_train"],
explicit_train=args.train,
)
if decision.base_branch is None:
fail("start a shared train before creating this parallel task worktree")
kind = "fix" if args.hotfix else "task"
branch = f"gd-ops/{kind}/{args.task}-{args.slug}"
if WORK_PATTERN.fullmatch(branch) is None:
fail("generated work branch is invalid")
worktree = args.worktree.resolve()
ensure_new_branch(root, branch, worktree)
base = decision.base_branch
commands = [
["git", "fetch", "origin", base],
[
"git",
"worktree",
"add",
"-b",
branch,
str(worktree),
f"refs/remotes/origin/{base}",
],
]
if not args.apply:
command_plan(
commands,
lane=decision.lane,
reason=decision.reason,
branch=branch,
base=base,
)
return
for command in commands:
run(command, cwd=root)
def transform(current: dict[str, Any]) -> dict[str, Any]:
current["branch_bases"][branch] = base
return current
update_state(root, transform)
print(
json.dumps(
{"lane": decision.lane, "reason": decision.reason, "branch": branch, "base": base},
indent=2,
sort_keys=True,
)
)
def pr_open(args: argparse.Namespace, root: pathlib.Path) -> None:
worktree = args.worktree.resolve()
branch = run(
["git", "branch", "--show-current"], cwd=worktree
).stdout.strip()
if WORK_PATTERN.fullmatch(branch) is None and TRAIN_PATTERN.fullmatch(branch) is None:
fail("PR head must be a managed task, fix, or train branch")
state_path, _ = common_state_paths(root)
state = read_state(state_path)
base = state["branch_bases"].get(branch)
if base is None:
base = MAIN_BRANCH if TRAIN_PATTERN.fullmatch(branch) else None
if base is None:
fail("branch base metadata is missing; recreate the worktree with this tool")
repository = repository_name(root)
commands = [
["git", "-C", str(worktree), "push", "-u", "origin", branch],
[
"gh",
"pr",
"create",
"--repo",
repository,
"--head",
branch,
"--base",
base,
"--title",
args.title,
*(
["--body-file", str(args.body_file.resolve())]
if args.body_file
else ["--fill"]
),
*([] if args.ready else ["--draft"]),
],
]
if not args.apply:
command_plan(commands, branch=branch, base=base, ready=args.ready)
return
if run(["git", "status", "--porcelain"], cwd=worktree).stdout.strip():
fail("PR worktree must be clean before push")
run(commands[0], cwd=root)
created = run(commands[1], cwd=root).stdout.strip()
if not created.startswith("https://github.com/"):
fail("gh did not return a pull request URL")
if args.ready:
run(
["gh", "pr", "merge", "--repo", repository, "--auto", "--squash", created],
cwd=root,
)
print(json.dumps({"url": created, "branch": branch, "base": base}, sort_keys=True))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path.cwd())
subparsers = parser.add_subparsers(dest="command", required=True)
decide = subparsers.add_parser("decide")
decide.add_argument("--lane", choices=("auto", "direct", "train"), default="auto")
decide.add_argument("--hotfix", action="store_true")
decide.add_argument("--small", action="store_true")
decide.add_argument("--agents", type=int, default=1)
decide.add_argument("--cross-component", action="store_true")
decide.add_argument("--train")
ci = subparsers.add_parser("ci-context")
ci.add_argument("--event", choices=("pull_request", "push"), required=True)
ci.add_argument("--base", required=True)
ci.add_argument("--head", default="")
ci.add_argument("--draft", choices=("true", "false"), default="false")
ci.add_argument("--format", choices=("json", "github"), default="json")
status = subparsers.add_parser("status")
status.set_defaults()
train = subparsers.add_parser("train-start")
train.add_argument("--name", required=True)
train.add_argument("--worktree", type=pathlib.Path, required=True)
train.add_argument("--apply", action="store_true")
clear = subparsers.add_parser("train-clear")
clear.add_argument("--train", required=True)
clear.add_argument("--apply", action="store_true")
task = subparsers.add_parser("task-start")
task.add_argument("--task", required=True)
task.add_argument("--slug", required=True)
task.add_argument("--worktree", type=pathlib.Path, required=True)
task.add_argument("--lane", choices=("auto", "direct", "train"), default="auto")
task.add_argument("--train")
task.add_argument("--hotfix", action="store_true")
task.add_argument("--small", action="store_true")
task.add_argument("--agents", type=int, default=1)
task.add_argument("--cross-component", action="store_true")
task.add_argument("--apply", action="store_true")
pr = subparsers.add_parser("pr-open")
pr.add_argument("--worktree", type=pathlib.Path, required=True)
pr.add_argument("--title", required=True)
pr.add_argument("--body-file", type=pathlib.Path)
pr.add_argument("--ready", action="store_true")
pr.add_argument("--apply", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
root = repository_root(args.root)
state_path, _ = common_state_paths(root)
if args.command == "decide":
state = read_state(state_path)
decision = choose_lane(
args.lane,
hotfix=args.hotfix,
small=args.small,
agents=args.agents,
cross_component=args.cross_component,
active_train=state["active_train"],
explicit_train=args.train,
)
print(json.dumps(asdict(decision), sort_keys=True))
elif args.command == "ci-context":
context = ci_context(args.event, args.base, args.head, args.draft == "true")
if args.format == "github":
for key, value in asdict(context).items():
rendered = "1" if value is True else "0" if value is False else value
print(f"{key}={rendered}")
else:
print(json.dumps(asdict(context), sort_keys=True))
elif args.command == "status":
print(json.dumps(read_state(state_path), indent=2, sort_keys=True))
elif args.command == "train-start":
train_start(args, root)
elif args.command == "train-clear":
if TRAIN_PATTERN.fullmatch(args.train) is None:
fail("train branch is invalid")
if not args.apply:
command_plan([], action="clear-active-train", train=args.train)
else:
def transform(state: dict[str, Any]) -> dict[str, Any]:
if state["active_train"] != args.train:
fail("requested train is not the active train")
state["active_train"] = None
return state
print(json.dumps(update_state(root, transform), indent=2, sort_keys=True))
elif args.command == "task-start":
task_start(args, root)
else:
pr_open(args, root)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
print(f"development-flow: {error}", file=sys.stderr)
raise SystemExit(1)
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""Create and validate source proof reused by the GitHub Release workflow."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import pathlib
import re
import stat
import subprocess
import sys
import tempfile
from typing import Any
SCHEMA_VERSION = "apc.release-source-proof.v1"
WORKFLOW_PATH = ".github/workflows/ci.yml"
EXPECTED_GATES = [
"bundle",
"contracts",
"overlay",
"rust-lint",
"rust-tests",
"static",
"stress",
"swift-interaction",
]
TOOLCHAIN_CONTRACT_FILES = [
"Cargo.lock",
"apps/macos/Package.resolved",
"apps/macos/Package.swift",
"rust-toolchain.toml",
"script/validate_macos_build_contract.py",
]
COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}")
REPOSITORY_PATTERN = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+")
TAG_PATTERN = re.compile(r"v[0-9]+[.][0-9]+[.][0-9]+")
def run(root: pathlib.Path, *command: str) -> str:
return subprocess.check_output(command, cwd=root, text=True).strip()
def sha256(path: pathlib.Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def contract_digest(root: pathlib.Path) -> str:
digest = hashlib.sha256()
for relative in TOOLCHAIN_CONTRACT_FILES:
path = root / relative
metadata = path.lstat()
if not stat.S_ISREG(metadata.st_mode) or path.is_symlink():
raise ValueError(f"toolchain contract entry is not a regular file: {relative}")
digest.update(relative.encode("utf-8"))
digest.update(b"\0")
digest.update(path.read_bytes())
digest.update(b"\0")
return digest.hexdigest()
def read_json_regular(path: pathlib.Path) -> dict[str, Any]:
try:
descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
except OSError as error:
raise ValueError(f"proof input must be a regular, non-symlink file: {path.name}") from error
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > 1024 * 1024:
raise ValueError(f"proof input is not a bounded regular file: {path.name}")
with os.fdopen(descriptor, encoding="utf-8") as handle:
descriptor = -1
payload = json.load(handle)
finally:
if descriptor >= 0:
os.close(descriptor)
if not isinstance(payload, dict):
raise ValueError(f"proof input must contain one JSON object: {path.name}")
return payload
def require_identity(repository: str, commit: str, previous_release_tag: str) -> None:
if REPOSITORY_PATTERN.fullmatch(repository) is None:
raise ValueError("repository must be owner/name")
if COMMIT_PATTERN.fullmatch(commit) is None:
raise ValueError("commit must be a full lowercase Git commit")
if TAG_PATTERN.fullmatch(previous_release_tag) is None:
raise ValueError("previous release tag must be vX.Y.Z")
def validate_attestation(
root: pathlib.Path, attestation: pathlib.Path, commit: str
) -> dict[str, Any]:
subprocess.run(
[
str(root / "script/validate_interaction_attestation.py"),
str(attestation),
"--root",
str(root),
"--expected-build-id",
f"source.{commit}",
],
cwd=root,
check=True,
)
return read_json_regular(attestation)
def observed_toolchains(root: pathlib.Path) -> dict[str, str]:
commands = {
"rustc": ("rustc", "-Vv"),
"swift": ("swift", "--version"),
"python": ("python3", "--version"),
"sdk": ("xcrun", "--show-sdk-version"),
}
values: dict[str, str] = {}
for name, command in commands.items():
value = subprocess.check_output(
command, cwd=root, text=True, stderr=subprocess.STDOUT
).strip()
if not value or len(value) > 4096:
raise ValueError(f"observed {name} toolchain identity is invalid")
values[name] = value
return values
def atomic_write_json(path: pathlib.Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
try:
os.fchmod(descriptor, 0o644)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(payload, handle, sort_keys=True, separators=(",", ":"))
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary_name, path)
finally:
try:
os.unlink(temporary_name)
except FileNotFoundError:
pass
def create(args: argparse.Namespace) -> None:
root = args.root.resolve()
require_identity(args.repository, args.commit, args.previous_release_tag)
if args.run_id <= 0 or args.run_attempt <= 0:
raise ValueError("run id and run attempt must be positive")
if run(root, "git", "rev-parse", "HEAD") != args.commit:
raise ValueError("proof checkout does not match the requested commit")
if run(root, "git", "status", "--porcelain", "--untracked-files=no"):
raise ValueError("proof checkout contains tracked modifications")
attestation = args.attestation.resolve()
attestation_payload = validate_attestation(root, attestation, args.commit)
payload: dict[str, Any] = {
"schema_version": SCHEMA_VERSION,
"repository": args.repository,
"commit": args.commit,
"source_tree": run(root, "git", "rev-parse", "HEAD^{tree}"),
"previous_release_tag": args.previous_release_tag,
"workflow_path": WORKFLOW_PATH,
"workflow_event": "push",
"workflow_ref": "refs/heads/main",
"run_id": args.run_id,
"run_attempt": args.run_attempt,
"gates": EXPECTED_GATES,
"toolchain_contract_digest": contract_digest(root),
"observed_toolchains": observed_toolchains(root),
"interaction_attestation_sha256": sha256(attestation),
"interaction_contract_digest": attestation_payload.get(
"interaction_contract_digest"
),
"ok": True,
}
atomic_write_json(args.output.resolve(), payload)
def validate(args: argparse.Namespace) -> None:
root = args.root.resolve()
require_identity(args.repository, args.commit, args.previous_release_tag)
if run(root, "git", "rev-parse", "HEAD") != args.commit:
raise ValueError("proof validation checkout does not match the requested commit")
if run(root, "git", "status", "--porcelain", "--untracked-files=no"):
raise ValueError("proof validation checkout contains tracked modifications")
proof = read_json_regular(args.proof.resolve())
expected_keys = {
"schema_version",
"repository",
"commit",
"source_tree",
"previous_release_tag",
"workflow_path",
"workflow_event",
"workflow_ref",
"run_id",
"run_attempt",
"gates",
"toolchain_contract_digest",
"observed_toolchains",
"interaction_attestation_sha256",
"interaction_contract_digest",
"ok",
}
if set(proof) != expected_keys:
raise ValueError("source proof field inventory is invalid")
checks = {
"schema_version": SCHEMA_VERSION,
"repository": args.repository,
"commit": args.commit,
"source_tree": run(root, "git", "rev-parse", "HEAD^{tree}"),
"previous_release_tag": args.previous_release_tag,
"workflow_path": WORKFLOW_PATH,
"workflow_event": "push",
"workflow_ref": "refs/heads/main",
"run_id": args.run_id,
"run_attempt": args.run_attempt,
"gates": EXPECTED_GATES,
"toolchain_contract_digest": contract_digest(root),
"ok": True,
}
for key, expected in checks.items():
if proof.get(key) != expected:
raise ValueError(f"source proof {key} does not match the release request")
observed = proof.get("observed_toolchains")
if not isinstance(observed, dict) or set(observed) != {"rustc", "swift", "python", "sdk"}:
raise ValueError("source proof observed toolchain inventory is invalid")
if any(not isinstance(value, str) or not value or len(value) > 4096 for value in observed.values()):
raise ValueError("source proof contains an invalid observed toolchain identity")
attestation = args.attestation.resolve()
proof_path = args.proof.resolve()
if proof_path.parent != attestation.parent:
raise ValueError("source proof files must share one artifact directory")
inventory = []
for child in proof_path.parent.iterdir():
metadata = child.lstat()
if not stat.S_ISREG(metadata.st_mode) or child.is_symlink():
raise ValueError("source proof artifact contains a non-regular entry")
inventory.append(child.name)
if sorted(inventory) != ["interaction-attestation.json", "source-proof.json"]:
raise ValueError("source proof artifact inventory must contain exactly two files")
attestation_payload = validate_attestation(root, attestation, args.commit)
if proof.get("interaction_attestation_sha256") != sha256(attestation):
raise ValueError("source proof interaction attestation digest is invalid")
if proof.get("interaction_contract_digest") != attestation_payload.get(
"interaction_contract_digest"
):
raise ValueError("source proof interaction contract digest is invalid")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--root", type=pathlib.Path, default=pathlib.Path(__file__).resolve().parent.parent
)
subparsers = parser.add_subparsers(dest="command", required=True)
for name in ("create", "validate"):
command = subparsers.add_parser(name)
command.add_argument("--repository", required=True)
command.add_argument("--commit", required=True)
command.add_argument("--previous-release-tag", required=True)
command.add_argument("--run-id", required=True, type=int)
command.add_argument("--run-attempt", required=True, type=int)
command.add_argument("--attestation", required=True, type=pathlib.Path)
if name == "create":
command.add_argument("--output", required=True, type=pathlib.Path)
else:
command.add_argument("--proof", required=True, type=pathlib.Path)
args = parser.parse_args()
try:
if args.command == "create":
create(args)
else:
validate(args)
except (OSError, ValueError, json.JSONDecodeError, subprocess.CalledProcessError) as error:
print(f"release source proof error: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Select the trusted main-push run and source-proof artifact for a release."""
from __future__ import annotations
import argparse
import json
import os
import pathlib
import re
import stat
import sys
from typing import Any
COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}")
REPOSITORY_PATTERN = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+")
WORKFLOW_PATH = ".github/workflows/ci.yml"
def read_json_regular(path: pathlib.Path) -> dict[str, Any]:
descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > 8 * 1024 * 1024:
raise ValueError("GitHub API input must be a bounded regular file")
with os.fdopen(descriptor, encoding="utf-8") as handle:
descriptor = -1
payload = json.load(handle)
finally:
if descriptor >= 0:
os.close(descriptor)
if not isinstance(payload, dict):
raise ValueError("GitHub API input must contain one JSON object")
return payload
def validate_identity(repository: str, commit: str) -> None:
if REPOSITORY_PATTERN.fullmatch(repository) is None:
raise ValueError("repository must be owner/name")
if COMMIT_PATTERN.fullmatch(commit) is None:
raise ValueError("commit must be a full lowercase Git commit")
def select_run(payload: dict[str, Any], repository: str, commit: str) -> dict[str, int]:
runs = payload.get("workflow_runs")
if not isinstance(runs, list):
raise ValueError("workflow run response is missing workflow_runs")
matches = []
for run in runs:
if not isinstance(run, dict):
continue
head_repository = run.get("head_repository")
head_name = head_repository.get("full_name") if isinstance(head_repository, dict) else None
if (
run.get("head_sha") == commit
and run.get("event") == "push"
and run.get("head_branch") == "main"
and run.get("conclusion") == "success"
and run.get("path") == WORKFLOW_PATH
and head_name == repository
and isinstance(run.get("id"), int)
and isinstance(run.get("run_attempt"), int)
):
matches.append(run)
if not matches:
raise ValueError("no successful trusted main-push CI run exists for the release commit")
selected = max(matches, key=lambda value: (value["id"], value["run_attempt"]))
return {"run_id": selected["id"], "run_attempt": selected["run_attempt"]}
def select_artifact(payload: dict[str, Any], run_id: int, artifact_name: str) -> dict[str, int | str]:
artifacts = payload.get("artifacts")
if not isinstance(artifacts, list):
raise ValueError("artifact response is missing artifacts")
matches = []
for artifact in artifacts:
if not isinstance(artifact, dict):
continue
workflow_run = artifact.get("workflow_run")
artifact_run_id = workflow_run.get("id") if isinstance(workflow_run, dict) else run_id
if (
artifact.get("name") == artifact_name
and artifact.get("expired") is False
and artifact_run_id == run_id
and isinstance(artifact.get("id"), int)
and isinstance(artifact.get("size_in_bytes"), int)
and 0 < artifact["size_in_bytes"] <= 1024 * 1024
):
matches.append(artifact)
if len(matches) != 1:
raise ValueError("trusted CI run must contain exactly one bounded source-proof artifact")
selected = matches[0]
return {
"artifact_id": selected["id"],
"artifact_name": artifact_name,
"size_in_bytes": selected["size_in_bytes"],
}
def main() -> int:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
run_parser = subparsers.add_parser("run")
run_parser.add_argument("--runs-json", required=True, type=pathlib.Path)
run_parser.add_argument("--repository", required=True)
run_parser.add_argument("--commit", required=True)
artifact_parser = subparsers.add_parser("artifact")
artifact_parser.add_argument("--artifacts-json", required=True, type=pathlib.Path)
artifact_parser.add_argument("--run-id", required=True, type=int)
artifact_parser.add_argument("--artifact-name", required=True)
args = parser.parse_args()
try:
if args.command == "run":
validate_identity(args.repository, args.commit)
result = select_run(
read_json_regular(args.runs_json), args.repository, args.commit
)
else:
if args.run_id <= 0 or not args.artifact_name:
raise ValueError("run id and artifact name must be valid")
result = select_artifact(
read_json_regular(args.artifacts_json), args.run_id, args.artifact_name
)
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"release source proof resolution error: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -700,19 +700,22 @@ class ReleaseWorkflowContractTests(unittest.TestCase):
self.source = (ROOT / ".github/workflows/release.yml").read_text(
encoding="utf-8"
)
ensure_tag_start = self.source.index("\n ensure_tag:")
build_start = self.source.index("\n build_archives:")
assemble_start = self.source.index("\n assemble:")
arm_start = self.source.index("\n validate_arm64:")
x86_start = self.source.index("\n validate_x86_64:")
macos26_start = self.source.index("\n validate_macos26:")
publish_start = self.source.index("\n publish:")
self.prepare = self.source[:build_start]
self.prepare = self.source[:ensure_tag_start]
self.ensure_tag = self.source[ensure_tag_start:build_start]
self.build = self.source[build_start:assemble_start]
self.assemble = self.source[assemble_start:arm_start]
self.arm = self.source[arm_start:x86_start]
self.x86 = self.source[x86_start:macos26_start]
self.macos26 = self.source[macos26_start:publish_start]
self.publish = self.source[publish_start:]
self.ci = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")
self.test_all = (ROOT / "script/test_all.sh").read_text(encoding="utf-8")
self.overlay_interaction = (
ROOT / "script/validate_overlay_interaction.sh"
@@ -782,16 +785,13 @@ class ReleaseWorkflowContractTests(unittest.TestCase):
release_builder = (ROOT / "script/build_release.sh").read_text(
encoding="utf-8"
)
self.assertIn("Prepare pinned Python validation environment", self.prepare)
self.assertIn("Pillow==11.3.0", self.prepare)
self.assertIn('Image.__version__ != "11.3.0"', self.prepare)
self.assertIn('features.check("webp_anim")', self.prepare)
self.assertIn('>>"$GITHUB_PATH"', self.prepare)
self.assertIn("./script/test_all.sh --source-only --include-stress", self.prepare)
self.assertLess(
self.prepare.index("Prepare pinned Python validation environment"),
self.prepare.index("./script/test_all.sh"),
)
self.assertIn("Prepare pinned Python validation environment", self.build)
self.assertIn("Pillow==11.3.0", self.build)
self.assertIn('>>"$GITHUB_PATH"', self.build)
self.assertIn("Reuse exact main source proof", self.prepare)
self.assertIn("./script/release_source_proof.py validate", self.prepare)
self.assertIn("./script/resolve_release_source_proof.py run", self.prepare)
self.assertNotIn("./script/test_all.sh", self.prepare)
self.assertIn(
'run: ./script/build_release.sh --github-release --source-gate-proven --arch "${{ matrix.architecture }}"',
self.build,
@@ -819,6 +819,7 @@ class ReleaseWorkflowContractTests(unittest.TestCase):
"APC_PREVIOUS_RELEASE_TAG: ${{ needs.prepare.outputs.previous_tag }}",
self.build,
)
self.assertIn("release-source-proof-", self.prepare)
self.assertIn("release-interaction-attestation", self.prepare)
self.assertIn("release-interaction-attestation", self.build)
self.assertIn("merge-multiple: true", self.assemble)
@@ -834,13 +835,18 @@ class ReleaseWorkflowContractTests(unittest.TestCase):
def test_release_source_gate_executes_phase_a_and_t_b4_swift_suites(self) -> None:
self.assertIn(
"Run source, complete Swift interaction, integration, and stress gates",
self.prepare,
"Run complete Swift suite and create source interaction proof",
self.ci,
)
self.assertIn(
"APC_BUILD_ID: ${{ steps.release_identity.outputs.version }}.${{ steps.release_identity.outputs.build }}.${{ steps.release_identity.outputs.commit }}",
self.prepare,
"APC_BUILD_ID: source.${{ github.sha }}",
self.ci,
)
self.assertIn("Run release-grade bounded event storm", self.ci)
self.assertIn("./script/validate_event_storm.sh", self.ci)
self.assertIn("./script/validate_overlay_offline.sh", self.ci)
self.assertIn("Upload exact-commit release source proof", self.ci)
self.assertIn("--proof-in \"$RUNNER_TEMP/source-proof/interaction-attestation.json\"", self.prepare)
self.assertIn(
'"$ROOT_DIR/script/prepare_interaction_attestation.sh"', self.test_all
)
@@ -865,9 +871,10 @@ class ReleaseWorkflowContractTests(unittest.TestCase):
with self.subTest(suite=suite):
self.assertIn(suite, self.overlay_interaction)
def test_only_publish_job_can_write_repository_contents(self) -> None:
self.assertEqual(self.source.count("contents: write"), 1)
def test_only_tag_binding_and_publish_can_write_repository_contents(self) -> None:
self.assertEqual(self.source.count("contents: write"), 2)
self.assertNotIn("contents: write", self.prepare)
self.assertIn("contents: write", self.ensure_tag)
self.assertNotIn("contents: write", self.build)
self.assertNotIn("contents: write", self.assemble)
self.assertNotIn("contents: write", self.arm)
@@ -875,6 +882,19 @@ class ReleaseWorkflowContractTests(unittest.TestCase):
self.assertNotIn("contents: write", self.macos26)
self.assertIn("contents: write", self.publish)
def test_dispatch_promotes_a_proven_main_commit_and_creates_only_a_missing_tag(self) -> None:
self.assertIn("Successful main commit to promote", self.prepare)
self.assertIn("inputs.commit || 'main'", self.prepare)
self.assertIn('echo "tag_exists=$tag_exists"', self.prepare)
proof_validation = self.prepare.index("./script/release_source_proof.py validate")
self.assertGreater(proof_validation, self.prepare.index("Resolve successful trusted main CI run"))
self.assertIn("needs: prepare", self.ensure_tag)
self.assertIn("if: env.TAG_EXISTS != '1'", self.ensure_tag)
self.assertIn('"repos/$GITHUB_REPOSITORY/git/refs"', self.ensure_tag)
self.assertIn('-f "ref=refs/tags/$RELEASE_TAG"', self.ensure_tag)
self.assertIn("needs: [prepare, ensure_tag]", self.build)
self.assertNotIn("cargo test", self.ensure_tag)
def test_downstream_jobs_use_proven_commit_and_recheck_remote_tag(self) -> None:
self.assertNotIn("ref: ${{ needs.prepare.outputs.tag }}", self.source)
self.assertGreaterEqual(
@@ -970,32 +990,50 @@ class CIWorkflowContractTests(unittest.TestCase):
def test_ci_uses_the_shared_scope_router_and_conditional_bundle(self) -> None:
source = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")
self.assertIn("./script/validation_scope.py", source)
self.assertIn("./script/validate_pre_push.sh", source)
self.assertIn("--ci", source)
self.assertIn("if: steps.validation_scope.outputs.bundle == '1'", source)
self.assertNotIn("./script/validate_pre_push.sh", source)
self.assertIn("needs.scope.outputs.bundle == '1'", source)
self.assertIn("--validation full", source)
self.assertIn("--interaction-attestation", source)
self.assertIn("shard: [core, integration-a, integration-b, integration-c, integration-d]", source)
self.assertIn("name: Required CI", source)
self.assertIn("release-source-proof-${{ github.sha }}", source)
self.assertIn("Computer Use is recommended", source)
self.assertNotIn("APC_VALIDATE_HOST_UI: \"1\"", source)
self.assertNotIn("cargo test --workspace", source)
self.assertNotIn("working-directory: apps/macos", source)
self.assertIn("Rebind source interaction proof to the development build identity", source)
self.assertIn("--proof-in \"$RUNNER_TEMP/interaction-proof/interaction-attestation.json\"", source)
self.assertIn("APC_BUILD_ID: ${{ steps.development_identity.outputs.build_id }}", source)
self.assertIn("development-attestation.json", source)
self.assertGreaterEqual(source.count("exit 1 ;;"), 4)
self.assertIn('FULL_CANDIDATE: ${{ needs.scope.outputs.full_candidate }}', source)
self.assertIn("complete main-bound job did not succeed", source)
def test_ready_protected_prs_enable_auto_merge_without_running_pr_code(self) -> None:
source = (ROOT / ".github/workflows/auto-merge.yml").read_text(
encoding="utf-8"
)
self.assertIn("pull_request_target:", source)
self.assertIn("github.event.pull_request.head.repo.full_name == github.repository", source)
self.assertIn("startsWith(github.head_ref, 'gd-ops/')", source)
self.assertIn('repos/$GITHUB_REPOSITORY/rules/branches/$encoded_base', source)
self.assertIn('.context == "Required CI"', source)
self.assertIn("gh pr merge", source)
self.assertIn("--auto --squash", source)
self.assertNotIn("actions/checkout", source)
self.assertNotRegex(source, r"(?m)^\s*run:\s*[.]/")
def test_ci_prepares_producer_image_dependencies_and_pins_actions(self) -> None:
source = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")
self.assertIn("Prepare pinned Python validation environment", source)
self.assertIn("steps.validation_scope.outputs.producer == '1'", source)
self.assertIn("needs.scope.outputs.producer == '1'", source)
self.assertIn("Pillow==11.3.0", source)
self.assertIn('features.check("webp_anim")', source)
self.assertNotIn(
"if: steps.validation_scope.outputs.docs_only != '1'", source
)
self.assertGreaterEqual(
source.count(
"if: steps.validation_scope.outputs.bundle == '1' || "
"steps.validation_scope.outputs.rust_mode != 'none' || "
"steps.validation_scope.outputs.swift_mode != 'none'"
),
2,
)
self.assertIn("needs.scope.outputs.full_candidate == '1'", source)
self.assertIn("needs.scope.outputs.release_source", source)
uses = re.findall(r"(?m)^\s*-\s+uses:\s+([^#\s]+)", source)
self.assertTrue(uses)
for action in uses:
+366
View File
@@ -10,6 +10,8 @@ import subprocess
import sys
import tempfile
import unittest
from argparse import Namespace
from unittest import mock
ROOT = pathlib.Path(__file__).resolve().parents[2]
@@ -18,6 +20,12 @@ FINGERPRINT = ROOT / "script/validation_fingerprint.py"
INTERACTION_VALIDATOR = ROOT / "script/validate_interaction_attestation.py"
OVERLAY_INTERACTION = ROOT / "script/validate_overlay_interaction.sh"
VALIDATION_SCOPE_PATH = ROOT / "script/validation_scope.py"
RELEASE_SOURCE_PROOF_PATH = ROOT / "script/release_source_proof.py"
RESOLVE_SOURCE_PROOF_PATH = ROOT / "script/resolve_release_source_proof.py"
RUST_TEST_SHARDS_PATH = ROOT / "script/validate_rust_test_shards.py"
MAIN_BRANCH_RULESET_PATH = ROOT / "script/configure_main_branch_ruleset.py"
DEVELOPMENT_FLOW_PATH = ROOT / "script/development_flow.py"
CHANGELOG_FRAGMENTS_PATH = ROOT / "script/changelog_fragments.py"
INTERACTION_SUITES = [
"OverlayPlacementAuthorityTests",
"AppStoreOverlaySnapshotTests",
@@ -37,6 +45,12 @@ def load_module(name: str, path: pathlib.Path):
validation_scope = load_module("apc_validation_scope", VALIDATION_SCOPE_PATH)
release_source_proof = load_module("apc_release_source_proof", RELEASE_SOURCE_PROOF_PATH)
resolve_source_proof = load_module("apc_resolve_source_proof", RESOLVE_SOURCE_PROOF_PATH)
rust_test_shards = load_module("apc_rust_test_shards", RUST_TEST_SHARDS_PATH)
main_branch_ruleset = load_module("apc_main_branch_ruleset", MAIN_BRANCH_RULESET_PATH)
development_flow = load_module("apc_development_flow", DEVELOPMENT_FLOW_PATH)
changelog_fragments = load_module("apc_changelog_fragments", CHANGELOG_FRAGMENTS_PATH)
def write_localizations(root: pathlib.Path, *, chinese_value: str = "宠物库") -> None:
@@ -134,6 +148,12 @@ class ValidationScopeTests(unittest.TestCase):
self.assertFalse(scope.bundle)
self.assertEqual(scope.computer_use, "not_required")
def test_changelog_fragment_only_is_documentation_scope(self) -> None:
scope = validation_scope.classify(["changes/unreleased/task-42.json"])
self.assertTrue(scope.docs_only)
self.assertEqual(scope.rust_mode, "none")
self.assertFalse(scope.bundle)
def test_overlay_change_uses_focused_swift_and_recommends_live_ui(self) -> None:
scope = validation_scope.classify(
["apps/macos/Sources/AgentPetCompanion/Overlay/OverlayRootView.swift"]
@@ -245,5 +265,351 @@ class InteractionProofTests(unittest.TestCase):
self.assertEqual(payload["interaction_contract_digest"], digest.hexdigest())
class RustTestShardTests(unittest.TestCase):
def test_current_integration_inventory_is_assigned_exactly_once(self) -> None:
rust_test_shards.validate_inventory(ROOT)
assigned = [
target
for shard in rust_test_shards.SHARDS.values()
for target in shard
]
self.assertEqual(len(assigned), len(set(assigned)))
self.assertEqual(set(assigned), rust_test_shards.discovered_tests(ROOT))
def test_every_shard_has_a_bounded_explicit_plan(self) -> None:
for shard in rust_test_shards.ALL_SHARDS:
with self.subTest(shard=shard):
commands = rust_test_shards.commands(ROOT, shard)
self.assertTrue(commands)
self.assertTrue(all(command[:2] == ["cargo", "test"] for command in commands))
self.assertTrue(all("--locked" in command for command in commands))
class MainBranchRulesetTests(unittest.TestCase):
def test_ruleset_requires_pull_requests_and_the_github_actions_check(self) -> None:
payload = main_branch_ruleset.ruleset_payload(15368)
self.assertEqual(payload["enforcement"], "active")
self.assertEqual(
payload["conditions"]["ref_name"]["include"], ["~DEFAULT_BRANCH"]
)
rules = {rule["type"]: rule for rule in payload["rules"]}
self.assertIn("pull_request", rules)
self.assertIn("deletion", rules)
self.assertIn("non_fast_forward", rules)
required = rules["required_status_checks"]["parameters"]
self.assertTrue(required["strict_required_status_checks_policy"])
self.assertEqual(
required["required_status_checks"],
[{"context": "Required CI", "integration_id": 15368}],
)
pull_requests = rules["pull_request"]["parameters"]
self.assertEqual(pull_requests["allowed_merge_methods"], ["squash"])
def test_train_ruleset_is_non_strict_and_allows_post_merge_deletion(self) -> None:
payload = main_branch_ruleset.ruleset_payload(15368, train=True)
self.assertEqual(
payload["conditions"]["ref_name"]["include"],
["refs/heads/gd-ops/train/*"],
)
rules = {rule["type"]: rule for rule in payload["rules"]}
self.assertNotIn("deletion", rules)
self.assertFalse(
rules["required_status_checks"]["parameters"]
["strict_required_status_checks_policy"]
)
def test_repository_settings_enable_auto_merge_and_squash_only(self) -> None:
self.assertEqual(
main_branch_ruleset.repository_settings_payload(),
{
"allow_auto_merge": True,
"delete_branch_on_merge": True,
"allow_merge_commit": False,
"allow_rebase_merge": False,
"allow_squash_merge": True,
"allow_update_branch": True,
"squash_merge_commit_title": "PR_TITLE",
"squash_merge_commit_message": "PR_BODY",
},
)
def test_only_accepts_a_successful_completed_github_actions_check(self) -> None:
valid = {
"id": 42,
"name": "Required CI",
"status": "completed",
"conclusion": "success",
"completed_at": "2026-08-11T10:00:00Z",
"details_url": "https://github.com/owner/repo/actions/runs/99/job/42",
"app": {"id": 15368, "slug": "github-actions"},
}
selected = main_branch_ruleset.select_required_check(
{
"check_runs": [
dict(valid, id=1, conclusion="failure"),
dict(valid, id=2, app={"id": 7, "slug": "foreign-app"}),
valid,
]
},
"owner/repo",
)
self.assertEqual(selected["check_run_id"], 42)
self.assertEqual(selected["integration_id"], 15368)
self.assertEqual(selected["workflow_run_id"], 99)
with self.assertRaisesRegex(ValueError, "no successful"):
main_branch_ruleset.select_required_check({"check_runs": []}, "owner/repo")
def test_required_check_must_belong_to_the_exact_trusted_workflow_run(self) -> None:
commit = "a" * 40
trusted = {
"path": ".github/workflows/ci.yml",
"event": "push",
"head_branch": "main",
"head_sha": commit,
"status": "completed",
"conclusion": "success",
"head_repository": {"full_name": "owner/repo"},
}
main_branch_ruleset.validate_workflow_run(
trusted, "owner/repo", "main", commit
)
with self.assertRaisesRegex(ValueError, "does not belong"):
main_branch_ruleset.validate_workflow_run(
dict(trusted, event="pull_request"), "owner/repo", "main", commit
)
def test_existing_ruleset_selection_rejects_ambiguous_ownership(self) -> None:
self.assertEqual(
main_branch_ruleset.existing_ruleset_id(
[{"id": 9, "name": main_branch_ruleset.RULESET_NAME, "target": "branch"}]
),
9,
)
with self.assertRaisesRegex(ValueError, "multiple"):
main_branch_ruleset.existing_ruleset_id(
[
{"id": 9, "name": main_branch_ruleset.RULESET_NAME, "target": "branch"},
{"id": 10, "name": main_branch_ruleset.RULESET_NAME, "target": "branch"},
]
)
class DevelopmentFlowTests(unittest.TestCase):
def test_agent_chooses_direct_or_shared_train_lane(self) -> None:
direct = development_flow.choose_lane(
"auto",
hotfix=True,
small=False,
agents=3,
cross_component=True,
active_train="gd-ops/train/release-1",
)
self.assertEqual((direct.lane, direct.base_branch), ("direct", "main"))
train = development_flow.choose_lane(
"auto",
hotfix=False,
small=False,
agents=3,
cross_component=True,
active_train="gd-ops/train/release-1",
)
self.assertEqual(
(train.lane, train.base_branch),
("train", "gd-ops/train/release-1"),
)
missing = development_flow.choose_lane(
"auto",
hotfix=False,
small=False,
agents=2,
cross_component=False,
active_train=None,
)
self.assertEqual((missing.lane, missing.base_branch), ("train", None))
def test_ci_context_makes_every_ready_main_pr_a_full_candidate(self) -> None:
direct = development_flow.ci_context(
"pull_request", "main", "gd-ops/fix/42-bubble", False
)
self.assertEqual(
(direct.lane, direct.full_candidate, direct.release_source),
("direct-to-main", True, False),
)
train = development_flow.ci_context(
"pull_request", "main", "gd-ops/train/release-1", False
)
self.assertEqual(
(train.lane, train.full_candidate), ("train-to-main", True)
)
draft = development_flow.ci_context(
"pull_request", "main", "gd-ops/task/42-feature", True
)
self.assertFalse(draft.full_candidate)
def test_task_pr_is_scoped_and_main_push_is_release_source(self) -> None:
task = development_flow.ci_context(
"pull_request",
"gd-ops/train/release-1",
"gd-ops/task/42-feature",
False,
)
self.assertEqual(
(task.lane, task.full_candidate, task.release_source),
("task-to-train", False, False),
)
push = development_flow.ci_context("push", "main", "", False)
self.assertEqual(
(push.lane, push.full_candidate, push.release_source),
("main-push", True, True),
)
class ChangelogFragmentTests(unittest.TestCase):
def test_fragment_roundtrip_renders_and_consumes_bilingual_entry(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
fragments = root / "changes/unreleased"
fragments.mkdir(parents=True)
path = fragments / "task-42.json"
path.write_text(
json.dumps(
{
"schema_version": changelog_fragments.SCHEMA_VERSION,
"kind": "Changed",
"scope": "workflow",
"summary_en": "Use train PRs for parallel work.",
"summary_zh": "并行工作使用 train PR。",
},
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
items = changelog_fragments.fragments(root)
rendered = changelog_fragments.render(items)
self.assertIn("### Changed / 变更", rendered)
self.assertIn("Use train PRs", rendered)
updated = changelog_fragments.insert_fragments(
"# Changelog\n\n## [Unreleased]\n\n## [1.0.0] - 2026-01-01\n",
items,
)
self.assertIn("并行工作使用 train PR。", updated)
self.assertLess(
updated.index("Use train PRs"), updated.index("## [1.0.0]")
)
class ReleaseSourceProofResolutionTests(unittest.TestCase):
def test_selects_only_successful_main_push_from_same_repository(self) -> None:
commit = "a" * 40
trusted = {
"id": 42,
"run_attempt": 2,
"head_sha": commit,
"event": "push",
"head_branch": "main",
"conclusion": "success",
"path": ".github/workflows/ci.yml",
"head_repository": {"full_name": "owner/repo"},
}
rejected = dict(trusted, id=99, event="pull_request")
selected = resolve_source_proof.select_run(
{"workflow_runs": [rejected, trusted]}, "owner/repo", commit
)
self.assertEqual(selected, {"run_id": 42, "run_attempt": 2})
def test_rejects_missing_or_duplicate_source_proof_artifact(self) -> None:
valid = {
"id": 7,
"name": "release-source-proof-abc",
"expired": False,
"size_in_bytes": 4096,
"workflow_run": {"id": 42},
}
selected = resolve_source_proof.select_artifact(
{"artifacts": [valid]}, 42, "release-source-proof-abc"
)
self.assertEqual(selected["artifact_id"], 7)
with self.assertRaisesRegex(ValueError, "exactly one"):
resolve_source_proof.select_artifact(
{"artifacts": [valid, dict(valid, id=8)]},
42,
"release-source-proof-abc",
)
class ReleaseSourceProofTests(unittest.TestCase):
def make_fixture(self, root: pathlib.Path) -> tuple[str, pathlib.Path]:
subprocess.run(["git", "init", "-q"], cwd=root, check=True)
subprocess.run(["git", "config", "user.email", "tests@example.com"], cwd=root, check=True)
subprocess.run(["git", "config", "user.name", "Tests"], cwd=root, check=True)
for relative in release_source_proof.TOOLCHAIN_CONTRACT_FILES:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"contract:{relative}\n", encoding="utf-8")
script = root / "script/validate_interaction_attestation.py"
script.write_text("#!/usr/bin/env python3\nraise SystemExit(0)\n", encoding="utf-8")
script.chmod(0o755)
subprocess.run(["git", "add", "."], cwd=root, check=True)
subprocess.run(["git", "commit", "-qm", "fixture"], cwd=root, check=True)
commit = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip()
artifact = root.parent / "artifact"
artifact.mkdir()
attestation = artifact / "interaction-attestation.json"
attestation.write_text(
json.dumps(
{
"build_id": f"source.{commit}",
"interaction_contract_digest": "b" * 64,
},
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
return commit, attestation
def test_roundtrip_is_bound_to_commit_run_and_attestation_bytes(self) -> None:
with tempfile.TemporaryDirectory() as directory:
temporary = pathlib.Path(directory)
root = temporary / "repo"
root.mkdir()
commit, attestation = self.make_fixture(root)
output = attestation.parent / "source-proof.json"
common = dict(
root=root,
repository="owner/repo",
commit=commit,
previous_release_tag="v1.2.3",
run_id=42,
run_attempt=2,
attestation=attestation,
)
attestation_payload = json.loads(attestation.read_text(encoding="utf-8"))
with mock.patch.object(
release_source_proof,
"validate_attestation",
return_value=attestation_payload,
), mock.patch.object(
release_source_proof,
"observed_toolchains",
return_value={
"rustc": "rustc fixture",
"swift": "swift fixture",
"python": "python fixture",
"sdk": "26.0",
},
):
release_source_proof.create(Namespace(**common, output=output))
release_source_proof.validate(Namespace(**common, proof=output))
proof_payload = json.loads(output.read_text(encoding="utf-8"))
self.assertEqual(proof_payload["gates"], release_source_proof.EXPECTED_GATES)
self.assertIn("bundle", proof_payload["gates"])
attestation.write_text(attestation.read_text(encoding="utf-8") + " ", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "attestation digest"):
release_source_proof.validate(Namespace(**common, proof=output))
if __name__ == "__main__":
unittest.main()
+97 -17
View File
@@ -41,6 +41,12 @@ RELEASE_SCRIPTS=(
"$ROOT_DIR/script/validate_release_artifact_metadata.py"
"$ROOT_DIR/script/validate_github_release_api.py"
"$ROOT_DIR/script/validate_codex_plugin_version.py"
"$ROOT_DIR/script/release_source_proof.py"
"$ROOT_DIR/script/resolve_release_source_proof.py"
"$ROOT_DIR/script/validate_rust_test_shards.py"
"$ROOT_DIR/script/configure_main_branch_ruleset.py"
"$ROOT_DIR/script/development_flow.py"
"$ROOT_DIR/script/changelog_fragments.py"
"$ROOT_DIR/script/validate_overlay_interaction.sh"
"$ROOT_DIR/script/prepare_interaction_attestation.sh"
"$ROOT_DIR/script/verify_release_candidate_digests.sh"
@@ -48,6 +54,7 @@ RELEASE_SCRIPTS=(
)
WORKFLOW="$ROOT_DIR/.github/workflows/release.yml"
CI_WORKFLOW="$ROOT_DIR/.github/workflows/ci.yml"
AUTO_MERGE_WORKFLOW="$ROOT_DIR/.github/workflows/auto-merge.yml"
TEST_ALL="$ROOT_DIR/script/test_all.sh"
OVERLAY_INTERACTION_VALIDATOR="$ROOT_DIR/script/validate_overlay_interaction.sh"
PRE_PUSH_VALIDATOR="$ROOT_DIR/script/validate_pre_push.sh"
@@ -84,13 +91,20 @@ if forbidden_workflow="$(rg -n \
exit 1
fi
# Local pre-push and ordinary CI share one path classifier. CI may assemble a
# full development bundle only when that classifier marks runtime inputs.
# Local pre-push and CI share only the path classifier. The local gate stays
# compile-only, while CI owns complete tests, source proof, and App assembly.
rg -Fq './script/validation_scope.py' "$CI_WORKFLOW"
rg -Fq './script/validate_pre_push.sh' "$CI_WORKFLOW"
rg -Fq -- '--ci' "$CI_WORKFLOW"
rg -Fq "if: steps.validation_scope.outputs.bundle == '1'" "$CI_WORKFLOW"
rg -Fq "steps.validation_scope.outputs.producer == '1'" "$CI_WORKFLOW"
rg -Fq './script/development_flow.py ci-context' "$CI_WORKFLOW"
rg -Fq './script/changelog_fragments.py validate' "$CI_WORKFLOW"
rg -Fq './script/changelog_fragments.py require-consumed' "$CI_WORKFLOW"
rg -Fq "needs.scope.outputs.full_candidate == '1'" "$CI_WORKFLOW"
rg -Fq "needs.scope.outputs.release_source == '1'" "$CI_WORKFLOW"
if rg -Fq './script/validate_pre_push.sh' "$CI_WORKFLOW"; then
echo 'CI must not couple its authoritative gates to the local fast gate' >&2
exit 1
fi
rg -Fq "needs.scope.outputs.bundle == '1'" "$CI_WORKFLOW"
rg -Fq "needs.scope.outputs.producer == '1'" "$CI_WORKFLOW"
rg -Fq 'Pillow==11.3.0' "$CI_WORKFLOW"
rg -Fq 'features.check("webp_anim")' "$CI_WORKFLOW"
rg -Fq 'pet_bytebudcodex|pet_pinklace|pet_xingwutuanzi' "$CI_WORKFLOW"
@@ -99,8 +113,48 @@ if rg -Fq "if: steps.validation_scope.outputs.docs_only != '1'" "$CI_WORKFLOW";
exit 1
fi
rg -Fq -- '--validation full' "$CI_WORKFLOW"
rg -Fq -- '--interaction-attestation' "$CI_WORKFLOW"
rg -Fq 'validate_rust_test_shards.py --shard' "$CI_WORKFLOW"
rg -Fq 'shard: [core, integration-a, integration-b, integration-c, integration-d]' "$CI_WORKFLOW"
rg -Fq 'name: Required CI' "$CI_WORKFLOW"
rg -Fq 'release-source-proof-${{ github.sha }}' "$CI_WORKFLOW"
rg -Fq './script/release_source_proof.py create' "$CI_WORKFLOW"
rg -Fq './script/validate_event_storm.sh' "$CI_WORKFLOW"
rg -Fq 'runs-on: macos-26' "$CI_WORKFLOW"
rg -Fq './script/validate_macos_build_contract.py toolchain' "$CI_WORKFLOW"
rg -Fq 'Rebind source interaction proof to the development build identity' "$CI_WORKFLOW"
rg -Fq -- '--proof-in "$RUNNER_TEMP/interaction-proof/interaction-attestation.json"' "$CI_WORKFLOW"
rg -Fq 'APC_BUILD_ID: ${{ steps.development_identity.outputs.build_id }}' "$CI_WORKFLOW"
rg -Fq 'development-attestation.json' "$CI_WORKFLOW"
rg -Fq 'FULL_CANDIDATE: ${{ needs.scope.outputs.full_candidate }}' "$CI_WORKFLOW"
if [[ "$(rg -c 'exit 1 ;;' "$CI_WORKFLOW")" -lt 4 ]]; then
echo 'CI result aggregation must fail explicitly instead of relying on shell loop status' >&2
exit 1
fi
rg -Fq 'RULESET_NAME = "Protected default branch"' \
"$ROOT_DIR/script/configure_main_branch_ruleset.py"
rg -Fq 'REQUIRED_CHECK = "Required CI"' \
"$ROOT_DIR/script/configure_main_branch_ruleset.py"
rg -Fq 'TRAIN_RULESET_NAME = "Protected integration trains"' \
"$ROOT_DIR/script/configure_main_branch_ruleset.py"
rg -Fq '"allow_auto_merge": True' \
"$ROOT_DIR/script/configure_main_branch_ruleset.py"
rg -Fq '"allow_merge_commit": False' \
"$ROOT_DIR/script/configure_main_branch_ruleset.py"
rg -Fq '"allow_rebase_merge": False' \
"$ROOT_DIR/script/configure_main_branch_ruleset.py"
rg -Fq 'if not args.apply:' "$ROOT_DIR/script/configure_main_branch_ruleset.py"
rg -Fq 'pull_request_target:' "$AUTO_MERGE_WORKFLOW"
rg -Fq 'github.event.pull_request.head.repo.full_name == github.repository' \
"$AUTO_MERGE_WORKFLOW"
rg -Fq 'repos/$GITHUB_REPOSITORY/rules/branches/$encoded_base' \
"$AUTO_MERGE_WORKFLOW"
rg -Fq '.context == "Required CI"' "$AUTO_MERGE_WORKFLOW"
rg -Fq -- '--auto --squash' "$AUTO_MERGE_WORKFLOW"
if rg -q 'actions/checkout|^[[:space:]]*run:[[:space:]]*[.]/' "$AUTO_MERGE_WORKFLOW"; then
echo 'pull_request_target auto-merge must never check out or execute PR code' >&2
exit 1
fi
if rg -q 'APC_VALIDATE_HOST_UI:[[:space:]]+"1"|computer-use|Computer Use.*run:' \
"$CI_WORKFLOW"; then
echo 'ordinary CI must not auto-enable live UI or Computer Use' >&2
@@ -141,9 +195,13 @@ rg -Fq 'validate_interaction_attestation.py' "$ROOT_DIR/script/validate_overlay_
rg -Fq 'validate_localizations.py' "$TEST_ALL"
rg -Fq 'validation_fingerprint.py' "$TEST_ALL"
rg -Fq -- '--resume' "$TEST_ALL"
rg -Fq 'test_all.sh" --resume' "$PRE_PUSH_VALIDATOR"
if rg -Fq 'test_all.sh --resume' "$WORKFLOW"; then
echo 'GitHub Release must not consume local validation checkpoints' >&2
if rg -q 'test_all[.]sh|cargo test|cargo clippy|validate_swift_tests|build_app_bundle|validate_portable_pet_maker|validate_connectors_runtime' \
"$PRE_PUSH_VALIDATOR"; then
echo 'local fast gate must not duplicate authoritative CI tests or App assembly' >&2
exit 1
fi
if rg -Fq 'test_all.sh' "$WORKFLOW"; then
echo 'GitHub Release must reuse the exact main source proof instead of repeating test_all' >&2
exit 1
fi
python3 - "$TEST_ALL" <<'PY'
@@ -156,8 +214,15 @@ rust_tests = source.index('"rust-tests"')
if localization >= rust_tests:
raise SystemExit("localization parity must run before expensive Rust tests")
PY
rg -Fq 'Run source, complete Swift interaction, integration, and stress gates' \
"$WORKFLOW"
rg -Fq 'Reuse exact main source proof' "$WORKFLOW"
rg -Fq './script/resolve_release_source_proof.py run' "$WORKFLOW"
rg -Fq './script/release_source_proof.py validate' "$WORKFLOW"
rg -Fq 'github-token: ${{ github.token }}' "$WORKFLOW"
rg -Fq 'run-id: ${{ steps.source_proof_run.outputs.run_id }}' "$WORKFLOW"
rg -Fq "inputs.commit || 'main'" "$WORKFLOW"
rg -Fq 'if: env.TAG_EXISTS != '\''1'\''' "$WORKFLOW"
rg -Fq '"repos/$GITHUB_REPOSITORY/git/refs"' "$WORKFLOW"
rg -Fq 'needs: [prepare, ensure_tag]' "$WORKFLOW"
# Local and GitHub Release Apps are ad-hoc signed. The official path is
# explicit, dual-architecture, protected-source-bound, and three-file-only.
@@ -273,6 +338,7 @@ rg -Fq './script/validate_macos_build_contract.py toolchain' "$WORKFLOW"
rg -q 'verify_release_candidate_digests.sh' "$WORKFLOW"
rg -q 'validate_github_release_api.py' "$WORKFLOW"
rg -q 'validate_codex_plugin_version.py' "$WORKFLOW"
rg -q 'actions: read' "$WORKFLOW"
rg -Fq 'APC_PREVIOUS_RELEASE_TAG: ${{ needs.prepare.outputs.previous_tag }}' \
"$WORKFLOW"
rg -q 'VERSIONED_SKILLS' "$ROOT_DIR/script/validate_codex_plugin_version.py"
@@ -304,13 +370,15 @@ import re
import sys
source = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")
ensure_tag_start = source.index("\n ensure_tag:")
build_start = source.index("\n build_archives:")
assemble_start = source.index("\n assemble:")
arm_start = source.index("\n validate_arm64:")
x86_start = source.index("\n validate_x86_64:")
macos26_start = source.index("\n validate_macos26:")
publish_start = source.index("\n publish:")
prepare = source[:build_start]
prepare = source[:ensure_tag_start]
ensure_tag = source[ensure_tag_start:build_start]
build = source[build_start:assemble_start]
assemble = source[assemble_start:arm_start]
arm = source[arm_start:x86_start]
@@ -318,8 +386,10 @@ x86 = source[x86_start:macos26_start]
macos26 = source[macos26_start:publish_start]
publish = source[publish_start:]
if source.count("contents: write") != 1 or "contents: write" not in publish:
raise SystemExit("only the publish job may have contents: write")
if source.count("contents: write") != 2:
raise SystemExit("only tag binding and publish may have contents: write")
if "contents: write" not in ensure_tag or "contents: write" not in publish:
raise SystemExit("tag binding and publish need explicit contents write permission")
if any("contents: write" in job for job in (prepare, build, assemble, arm, x86, macos26)):
raise SystemExit("build and validation jobs must remain read-only")
if source.count("ref: ${{ needs.prepare.outputs.commit }}") < 5:
@@ -327,15 +397,19 @@ if source.count("ref: ${{ needs.prepare.outputs.commit }}") < 5:
if source.count("./script/verify_remote_release_tag.sh") < 3:
raise SystemExit("remote tag identity must be rechecked before and after publication")
source_gate = prepare.index("./script/test_all.sh --source-only --include-stress")
proof_upload = prepare.index("Upload source-bound interaction proof", source_gate)
proof_resolve = prepare.index("Resolve successful trusted main CI run")
proof_download = prepare.index("Download exact-commit source proof", proof_resolve)
proof_validation = prepare.index("./script/release_source_proof.py validate", proof_download)
proof_upload = prepare.index("Upload release-bound interaction proof", proof_validation)
tag_create = ensure_tag.index("Create missing lightweight tag after source proof succeeds")
tag_verify = ensure_tag.index("./script/verify_remote_release_tag.sh", tag_create)
official_build = build.index(
'run: ./script/build_release.sh --github-release --source-gate-proven --arch "${{ matrix.architecture }}"'
)
metadata = assemble.index("validate_release_artifact_metadata.py")
digest_emission = assemble.index("Emit trusted digest for every candidate file", metadata)
upload = assemble.index("Upload exact release candidate", digest_emission)
if not source_gate < proof_upload or not metadata < digest_emission < upload:
if not proof_resolve < proof_download < proof_validation < proof_upload or not tag_create < tag_verify or not metadata < digest_emission < upload:
raise SystemExit("release source proof, assembly, digest, and upload order is unsafe")
if "architecture: [arm64, x86_64]" not in build or official_build < 0:
raise SystemExit("official architectures must build as a parallel matrix")
@@ -396,6 +470,12 @@ PY
"$ROOT_DIR/script/validation_fingerprint.py" --help >/dev/null
"$ROOT_DIR/script/validate_interaction_attestation.py" --help >/dev/null
"$ROOT_DIR/script/validation_scope.py" --help >/dev/null
"$ROOT_DIR/script/release_source_proof.py" --help >/dev/null
"$ROOT_DIR/script/resolve_release_source_proof.py" --help >/dev/null
"$ROOT_DIR/script/validate_rust_test_shards.py" --help >/dev/null
"$ROOT_DIR/script/configure_main_branch_ruleset.py" --help >/dev/null
"$ROOT_DIR/script/development_flow.py" --help >/dev/null
"$ROOT_DIR/script/changelog_fragments.py" --help >/dev/null
PYTHONDONTWRITEBYTECODE=1 \
python3 "$ROOT_DIR/script/tests/test_validation_tooling.py"
+15 -57
View File
@@ -4,17 +4,14 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BASE_REF=""
PLAN_ONLY=0
FULL=0
CI_MODE=0
usage() {
cat <<'EOF'
usage: validate_pre_push.sh [--base REF] [--plan-only] [--full] [--ci]
usage: validate_pre_push.sh [--base REF] [--plan-only]
Runs source-safe, change-scoped checks for an ordinary commit. --full delegates
to test_all.sh --resume; CI and Release continue to use uncached test_all.sh.
--ci uses the same path classifier but runs complete script/release contract
tests when those files changed. It never enables host UI or Computer Use.
Runs the bounded local fast gate: diff hygiene, source syntax, lightweight
contracts, formatting, and compilation for touched languages. Complete tests,
App assembly, stress, and artifact validation are authoritative remote gates.
EOF
}
@@ -29,14 +26,6 @@ while (($# > 0)); do
PLAN_ONLY=1
shift
;;
--full)
FULL=1
shift
;;
--ci)
CI_MODE=1
shift
;;
-h|--help)
usage
exit 0
@@ -48,14 +37,6 @@ while (($# > 0)); do
esac
done
if [[ "$FULL" == "1" ]]; then
if [[ "$PLAN_ONLY" == "1" ]]; then
echo './script/test_all.sh --resume'
exit 0
fi
exec "$ROOT_DIR/script/test_all.sh" --resume
fi
if [[ -z "$BASE_REF" ]]; then
if upstream="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)"; then
BASE_REF="$(git -C "$ROOT_DIR" merge-base HEAD "$upstream")"
@@ -85,29 +66,22 @@ if [[ "$APC_CHANGED_LOCALIZATION" == "1" ]]; then
PLAN+=("String Catalog/.strings parity")
fi
if [[ "$APC_CHANGED_SCRIPTS" == "1" ]]; then
PLAN+=("static build and release-script safety")
PLAN+=("static build and workflow contract safety")
fi
if [[ "$APC_CHANGED_PRODUCER" == "1" ]]; then
PLAN+=("pet Skill contracts and portable maker roundtrip")
fi
if [[ "$APC_CHANGED_CONNECTORS" == "1" ]]; then
PLAN+=("generated connector runtime smoke")
PLAN+=("pet Skill structure contracts")
fi
case "$APC_RUST_MODE" in
workspace) PLAN+=("Rust fmt plus workspace Clippy/tests") ;;
petcore) PLAN+=("Rust fmt plus petcore/petcore-cli Clippy/tests") ;;
cli) PLAN+=("Rust fmt plus petcore-cli Clippy/tests") ;;
workspace) PLAN+=("Rust format plus workspace compile check") ;;
petcore) PLAN+=("Rust format plus petcore/petcore-cli compile check") ;;
cli) PLAN+=("Rust format plus petcore-cli compile check") ;;
esac
case "$APC_SWIFT_MODE" in
overlay) PLAN+=("focused overlay, frame-pipeline, and interaction Swift tests") ;;
full) PLAN+=("complete Swift unit and UI-model tests") ;;
overlay|full) PLAN+=("Swift package compile check") ;;
esac
if [[ "$APC_CHANGED_PLUGIN_VERSION" == "1" ]]; then
PLAN+=("Codex plugin and bundled Skill version discipline")
fi
if [[ "$APC_BUILD_BUNDLE" == "1" && "$CI_MODE" == "1" ]]; then
PLAN+=("CI packaged development App proof")
fi
if [[ "$APC_COMPUTER_USE" == "recommended" ]]; then
PLAN+=("Computer Use recommended after automated checks; never run automatically")
else
@@ -149,13 +123,9 @@ if [[ "$APC_CHANGED_LOCALIZATION" == "1" ]]; then
run "Localization parity" "$ROOT_DIR/script/validate_localizations.py"
fi
if [[ "$APC_CHANGED_SCRIPTS" == "1" ]]; then
SCRIPT_SAFETY_ARGS=(--skip-source-syntax)
if [[ "$CI_MODE" != "1" ]]; then
SCRIPT_SAFETY_ARGS+=(--static-only)
fi
run "Build and release-script safety" \
"$ROOT_DIR/script/validate_build_scripts_safety.sh" \
"${SCRIPT_SAFETY_ARGS[@]}"
--skip-source-syntax --static-only
fi
if [[ "$APC_CHANGED_PLUGIN_VERSION" == "1" ]]; then
run "Codex plugin and Skill version discipline" \
@@ -164,10 +134,6 @@ if [[ "$APC_CHANGED_PLUGIN_VERSION" == "1" ]]; then
fi
if [[ "$APC_CHANGED_PRODUCER" == "1" ]]; then
run "Pet Skill contracts" "$ROOT_DIR/script/validate_pet_skills.sh"
run "Portable pet maker" "$ROOT_DIR/script/validate_portable_pet_maker.sh"
fi
if [[ "$APC_CHANGED_CONNECTORS" == "1" ]]; then
run "Connector runtime" "$ROOT_DIR/script/validate_connectors_runtime.sh"
fi
RUST_PACKAGE_ARGS=()
@@ -178,23 +144,15 @@ case "$APC_RUST_MODE" in
esac
if ((${#RUST_PACKAGE_ARGS[@]} > 0)); then
run "Rust formatting" cargo fmt --all --manifest-path "$ROOT_DIR/Cargo.toml" -- --check
run "Scoped Rust linting" cargo clippy \
--manifest-path "$ROOT_DIR/Cargo.toml" \
"${RUST_PACKAGE_ARGS[@]}" \
--all-targets --all-features --locked -- -D warnings
run "Scoped Rust tests" cargo test \
run "Scoped Rust compile check" cargo check \
--manifest-path "$ROOT_DIR/Cargo.toml" \
"${RUST_PACKAGE_ARGS[@]}" \
--locked
fi
case "$APC_SWIFT_MODE" in
overlay)
run "Focused overlay Swift tests" \
"$ROOT_DIR/script/validate_swift_tests.sh" --scope overlay
;;
full)
run "Swift tests" "$ROOT_DIR/script/validate_swift_tests.sh"
overlay|full)
run "Swift compile check" swift build --package-path "$ROOT_DIR/apps/macos"
;;
esac
echo 'Change-scoped pre-push validation passed. Use --full for the complete local gate.'
echo 'Local fast gate passed. GitHub CI remains authoritative for complete tests and App assembly.'
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Run complete, inventory-checked Rust test shards for CI and Release."""
from __future__ import annotations
import argparse
import pathlib
import subprocess
import sys
SHARDS: dict[str, list[tuple[str, str]]] = {
"integration-a": [
("petcore", "core_validation"),
("petcore", "daemon_http_security"),
("petcore", "petpack_resource_limits"),
("petcore", "schema_fixtures"),
],
"integration-b": [
("petcore", "app_server_transport"),
("petcore", "generation_history_delete"),
("petcore", "generation_jsonl_recovery"),
("petcore", "generation_lifecycle"),
],
"integration-c": [
("petcore", "generation_recovery"),
("petcore", "petpack_export"),
("petcore", "petpack_import_atomic"),
("petcore", "process_runner"),
("petcore", "reference_image_policy"),
],
"integration-d": [
("petcore", "agent_state_arbitration"),
("petcore", "bundled_pets"),
("petcore", "connector_contracts"),
("petcore", "connector_ordering"),
("petcore", "daemon_lifecycle"),
("petcore", "event_envelope_security"),
("petcore", "generation_form_contract"),
("petcore", "onboarding"),
("petcore", "petpack_v3_contract"),
("petcore", "runtime_manifest"),
("petcore-cli", "claude_session_message_routing"),
("petcore-cli", "petpack_build_metadata"),
("petcore-cli", "petpack_export_routing"),
("petcore-cli", "petpack_import_routing"),
],
}
ALL_SHARDS = ["core", *SHARDS]
def discovered_tests(root: pathlib.Path) -> set[tuple[str, str]]:
discovered: set[tuple[str, str]] = set()
for crate in sorted((root / "crates").iterdir()):
tests = crate / "tests"
if not tests.is_dir():
continue
for path in tests.glob("*.rs"):
discovered.add((crate.name, path.stem))
return discovered
def validate_inventory(root: pathlib.Path) -> None:
assigned = [target for shard in SHARDS.values() for target in shard]
if len(assigned) != len(set(assigned)):
raise ValueError("Rust integration test shard inventory contains duplicates")
discovered = discovered_tests(root)
assigned_set = set(assigned)
missing = sorted(discovered - assigned_set)
extra = sorted(assigned_set - discovered)
if missing or extra:
raise ValueError(f"Rust integration shard inventory mismatch: missing={missing} extra={extra}")
def commands(root: pathlib.Path, shard: str) -> list[list[str]]:
manifest = str(root / "Cargo.toml")
prefix = ["cargo", "test", "--manifest-path", manifest]
if shard == "core":
return [
[*prefix, "-p", "petcore", "--lib", "--bins", "--locked"],
[*prefix, "-p", "petcore-cli", "--bin", "petcore-cli", "--locked"],
[*prefix, "-p", "petcore-types", "--lib", "--locked"],
[*prefix, "--workspace", "--doc", "--locked"],
]
return [
[*prefix, "-p", package, "--test", target, "--locked"]
for package, target in SHARDS[shard]
]
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path(__file__).resolve().parent.parent)
parser.add_argument("--shard", choices=ALL_SHARDS)
parser.add_argument("--list-shards", action="store_true")
parser.add_argument("--plan", action="store_true")
args = parser.parse_args()
try:
root = args.root.resolve()
validate_inventory(root)
if args.list_shards:
print("\n".join(ALL_SHARDS))
return 0
if args.shard is None:
parser.error("--shard is required unless --list-shards is used")
planned = commands(root, args.shard)
if args.plan:
for command in planned:
print(" ".join(command))
return 0
for command in planned:
subprocess.run(command, cwd=root, check=True)
except (OSError, ValueError, subprocess.CalledProcessError) as error:
print(f"Rust test shard error: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4 -1
View File
@@ -170,7 +170,10 @@ def classify(paths: list[str]) -> ValidationScope:
"AGENTS.md",
)
docs_only = bool(normalized) and all(
path.startswith("docs/") or path in documentation_paths for path in normalized
path.startswith("docs/")
or path.startswith("changes/")
or path in documentation_paths
for path in normalized
) and not connectors
computer_use = (