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:
1 parent
4b7ff4a4d6
commit
790046d9a8
24 files changed
+3144
-225
No files matched your search
@@ -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:
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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` 或临时宠物素材。计划、进度、审计和命令输出不进入长期文档。
|
||||
@@ -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 回复。"
|
||||
}
|
||||
```
|
||||
@@ -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();
|
||||
|
||||
@@ -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 / 按版本记录的用户可见变化 |
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 / 环境门禁
|
||||
|
||||
|
||||
@@ -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 不会被覆盖。
|
||||
|
||||
|
||||
Executable
+238
@@ -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)
|
||||
Executable
+387
@@ -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)
|
||||
Executable
+531
@@ -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)
|
||||
Executable
+289
@@ -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())
|
||||
Executable
+133
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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.'
|
||||
Executable
+120
@@ -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())
|
||||
@@ -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 = (
|
||||
|
||||
Reference in new issue
Block a user