From f34183dba5e137ba8cbccbe37e96900abf098f27 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 04:22:25 +0500 Subject: [PATCH 01/21] Add Puber release control plane --- .github/workflows/pr-checks.yml | 58 +- .github/workflows/release.yml | 94 +- .kent/commands/cleanup-task.md | 47 +- .kent/commands/compliance-review.md | 75 +- .kent/commands/release-branch.md | 79 +- .kent/commands/release-tag.md | 53 +- .kent/commands/release.md | 70 +- .kent/commands/ship-pr.md | 70 +- .kent/project-contract.md | 17 + .../tests/test-github-release-workflow | 24 + .kent/scripts/tests/test-puber-release-graph | 20 + .../tests/test-runtime-v2-command-closure | 18 + .../tests/test-workflow-puber-release-intent | 19 + .../tests/test-workflow-puber-release-publish | 17 + .../tests/test-workflow-release-cleanup | 14 + .../scripts/tests/test-workflow-verify-report | 9 + .../tests/test-workflow-wait-github-release | 15 + .kent/scripts/workflow-compile-verify | 1 + .kent/scripts/workflow-evidence-ledger | 2 + .kent/scripts/workflow-puber-release-intent | 68 ++ .kent/scripts/workflow-puber-release-publish | 48 + .kent/scripts/workflow-release-cleanup | 34 + .kent/scripts/workflow-task-janitor | 4 +- .kent/scripts/workflow-verify-report | 4 +- .kent/scripts/workflow-wait-github-ci | 4 +- .kent/scripts/workflow-wait-github-pr | 4 +- .kent/scripts/workflow-wait-github-release | 48 + .kent/scripts/workflow_runtime_contracts.py | 86 ++ .kent/workflow-profile.toml | 2 +- .kent/workflows/README.md | 17 +- .kent/workflows/builders/puber_release.py | 71 ++ .kent/workflows/puber-release.json | 989 +++++++++++++++++- .kent/workflows/puber-release.manifest.json | 24 + .kent/workflows/specs/puber-release.toml | 10 + 34 files changed, 1641 insertions(+), 474 deletions(-) create mode 100755 .kent/scripts/tests/test-github-release-workflow create mode 100755 .kent/scripts/tests/test-puber-release-graph create mode 100755 .kent/scripts/tests/test-runtime-v2-command-closure create mode 100755 .kent/scripts/tests/test-workflow-puber-release-intent create mode 100755 .kent/scripts/tests/test-workflow-puber-release-publish create mode 100755 .kent/scripts/tests/test-workflow-release-cleanup create mode 100755 .kent/scripts/tests/test-workflow-verify-report create mode 100755 .kent/scripts/tests/test-workflow-wait-github-release create mode 100755 .kent/scripts/workflow-puber-release-intent create mode 100755 .kent/scripts/workflow-puber-release-publish create mode 100755 .kent/scripts/workflow-release-cleanup create mode 100755 .kent/scripts/workflow-wait-github-release create mode 100755 .kent/scripts/workflow_runtime_contracts.py create mode 100755 .kent/workflows/builders/puber_release.py create mode 100644 .kent/workflows/puber-release.manifest.json create mode 100644 .kent/workflows/specs/puber-release.toml diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 321a1f99..59478911 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -11,48 +11,34 @@ concurrency: jobs: detekt: name: Detekt - if: false runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up JDK 21 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: - java-version: '17' - distribution: 'temurin' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - + java-version: '21' + distribution: temurin - run: chmod +x gradlew - - name: Run Detekt - run: ./gradlew detektAll + run: ./gradlew :app:detektAll unit-tests: name: Unit Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up JDK 21 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: - java-version: '17' - distribution: 'temurin' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - + java-version: '21' + distribution: temurin - run: chmod +x gradlew - - name: Run unit tests - run: ./gradlew testProdDebugUnitTest - + run: ./gradlew :app:testProdDebugUnitTest - name: Upload test results if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: test-results path: app/build/reports/tests/ @@ -61,18 +47,12 @@ jobs: name: Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up JDK 21 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: - java-version: '17' - distribution: 'temurin' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - + java-version: '21' + distribution: temurin - run: chmod +x gradlew - - name: Build debug APK - run: ./gradlew assembleProdDebug + run: ./gradlew :app:assembleProdDebug diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a54e9af2..1e6eede6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,12 +4,6 @@ on: push: tags: - 'v*' - workflow_dispatch: - inputs: - release_tag: - description: 'Release tag to publish, for example v1.2.0' - required: true - type: string permissions: contents: write @@ -18,50 +12,33 @@ jobs: test: name: Unit Tests runs-on: ubuntu-latest - env: - RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }} steps: - - uses: actions/checkout@v4 - with: - ref: ${{ env.RELEASE_REF }} - - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up JDK 21 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: - java-version: '17' - distribution: 'temurin' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - + java-version: '21' + distribution: temurin - run: chmod +x gradlew - - name: Run unit tests - run: ./gradlew testProdDebugUnitTest + run: ./gradlew :app:testProdDebugUnitTest release: name: Build & Publish needs: test runs-on: ubuntu-latest env: - RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }} - RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} + RELEASE_TAG: ${{ github.ref_name }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: ${{ env.RELEASE_REF }} - - - name: Set up JDK 17 - uses: actions/setup-java@v4 + ref: ${{ github.ref }} + - name: Set up JDK 21 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: - java-version: '17' - distribution: 'temurin' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - + java-version: '21' + distribution: temurin - run: chmod +x gradlew - - name: Validate release secrets env: RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} @@ -72,17 +49,13 @@ jobs: run: | missing=() for name in RELEASE_KEYSTORE_BASE64 STOREPASS KEYALIAS PUBER_CLIENT_SECRET TMDB_READ_ACCESS_TOKEN; do - if [[ -z "${!name}" ]]; then - missing+=("$name") - fi + if [[ -z "${!name}" ]]; then missing+=("$name"); fi done - if (( ${#missing[@]} > 0 )); then printf 'Missing required GitHub Secrets:\n' >&2 printf ' - %s\n' "${missing[@]}" >&2 exit 1 fi - - name: Build release APK env: RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} @@ -91,34 +64,33 @@ jobs: KEYPASS: ${{ secrets.KEYPASS }} PUBER_CLIENT_SECRET: ${{ secrets.PUBER_CLIENT_SECRET }} TMDB_READ_ACCESS_TOKEN: ${{ secrets.TMDB_READ_ACCESS_TOKEN }} - run: ./gradlew assembleProdRelease - + run: ./gradlew :app:assembleProdRelease - name: Prepare release artifacts run: | mkdir -p release-artifacts apk_path="$(find app/build/outputs/apk/prod/release -maxdepth 1 -type f -name '*.apk' | head -n 1)" - if [[ -z "$apk_path" ]]; then - echo "Release APK was not produced" >&2 - exit 1 - fi - + test -n "$apk_path" cp "$apk_path" "release-artifacts/puber-${RELEASE_TAG}.apk" - ( - cd release-artifacts - shasum -a 256 "puber-${RELEASE_TAG}.apk" > "puber-${RELEASE_TAG}.apk.sha256" - ) - + (cd release-artifacts && shasum -a 256 "puber-${RELEASE_TAG}.apk" > "puber-${RELEASE_TAG}.apk.sha256") - name: Upload APK artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: puber-${{ env.RELEASE_TAG }} path: release-artifacts/* if-no-files-found: error - + - name: Prepare deterministic release notes + run: | + previous_tag="$(git tag --sort=-v:refname | grep -Fxv "$RELEASE_TAG" | head -n 1 || true)" + if [[ -n "$previous_tag" ]]; then + git log --format='- %s' "$previous_tag..$GITHUB_SHA" > "$RUNNER_TEMP/release-notes-en.md" + else + git log --format='- %s' "$GITHUB_SHA" -n 20 > "$RUNNER_TEMP/release-notes-en.md" + fi + test -s "$RUNNER_TEMP/release-notes-en.md" - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ env.RELEASE_TAG }} - name: Puber ${{ env.RELEASE_TAG }} - generate_release_notes: true - files: release-artifacts/* + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh release create "$RELEASE_TAG" --verify-tag + --title "Puber $RELEASE_TAG" --notes-file "$RUNNER_TEMP/release-notes-en.md" + release-artifacts/* diff --git a/.kent/commands/cleanup-task.md b/.kent/commands/cleanup-task.md index 15705b7b..3bef15a6 100644 --- a/.kent/commands/cleanup-task.md +++ b/.kent/commands/cleanup-task.md @@ -1,47 +1,10 @@ --- -description: Conservative Kent task cleanup report +description: Conservative Puber task cleanup report --- # Cleanup Task -Produces a conservative cleanup report for completed Kent workflow tasks. - -## Usage - -``` -/prompt:cleanup-task -/prompt:cleanup-task -``` - -## Policy - -The Cleanup agent is report-first and never removes its own Kent-managed -worktree. The generated Task Janitor runs after this session exits and owns -deterministic deletion. - -## What It Does - -1. Determine the primary checkout: - ```bash - git worktree list --porcelain - ``` -2. Inspect only the current task workspace and branch. -3. Report clean/dirty state, authoritative merged/no-PR proof, remote branch - state, and any unique content. -4. Do not invoke `git worktree remove` or `kent worktree delete` for the managed - task worktree. -5. Poll and close every task-owned background shell or kept-open tool session. -6. Run `kent worktree leave`; the Janitor will verify this session no longer - targets the task worktree. -7. Emit the complete `run_janitor` contract required by the workflow prompt. - -## Output - -Return a short human-readable `cleanup_report`: - -```markdown -Cleanup report: -- Preflight: exact task worktree and merged PR verified -- Preserved: none -- Handoff: Task Janitor may remove the clean managed worktree and task branch -``` +Inspect only the exact task worktree, branch, PR proof, and runtime carrier. +Report first. Preserve dirty, unpushed, unmerged, or unclear resources. The +schema-4 Janitor may delete only resources conclusively safe for the selected +cleanup mode; it never touches the primary checkout or live Kent state. diff --git a/.kent/commands/compliance-review.md b/.kent/commands/compliance-review.md index 2e88538b..1ce4edb6 100644 --- a/.kent/commands/compliance-review.md +++ b/.kent/commands/compliance-review.md @@ -1,74 +1,11 @@ --- -description: Read-only compliance review for workflow outputs +description: Read-only compliance review for Puber release outputs --- # Compliance Review -Run this only from a Kent workflow compliance node assigned to `compliance_reviewer`. - -## Purpose - -Review the plan and work product only for compliance with authoritative project rules, AGENTS.md, specs, user-approved -design choices, task body, and workflow contract. This is not general code review, architecture review, QA, or cleanup. - -## Authority Hierarchy - -Use this hierarchy, descending: - -1. The plan's Design section, when it clearly records user decisions. -2. The task body and human-authored task comments. -3. AGENTS.md rules. Treat changes to AGENTS.md in the worktree as unauthorized unless the task explicitly asked for them. -4. Spec files and project-local contracts. Treat changes to specs/contracts as unauthorized unless clearly based on #1 or #2. - -Agent-authored comments, implementation commentary, and previous review summaries are useful context, but not authority. - -## Required Inputs - -The workflow prompt must provide the available inputs: - -- `workspace_path`: the `.todo/` workspace or task workspace being reviewed, when one exists. -- `review_context`: for generated Delivery workflows, the canonical aggregate - containing plan/spec paths, reviewed scope, changed files, verification and - review reports, Gate decision, and Smoke evidence or bypass rationale. -- `plan_path` or `plan_file_path`: the authoritative plan, when one exists. -- `reviewed_scope`: what work product to inspect. -- `commentary`: implementation/review/verification summary from the previous node. -- `changed_files`: changed files, when known. - -When `review_context` contains these inputs, do not require duplicate standalone -fields. - -If a required source is missing, report the review as waiting for user action or incomplete and name the missing source. - -## Work Mode - -1. Read applicable AGENTS.md files first. -2. Read the plan/spec/contract sources named in the workflow prompt. -3. Inspect the reviewed scope and nearby files only as needed to verify compliance. -4. Treat the work as non-compliant by default until you verify it against the rules. -5. Report only direct compliance violations, spec mismatches, unauthorized rule/spec changes, missing required updates, or - ambiguity where a rule cannot be applied safely. -6. Do not edit files, apply patches, commit, mutate caches, or run state-changing commands. - -## Completion Contract - -Complete with: - -- The success transition named in the current workflow prompt when no compliance violations are found. New PR-producing - workflows use `ship_pr`; legacy no-PR release workflows may use `cleanup`. Provide `compliance_report`. -- `needs_changes` when compliance violations require a fix/rework pass. Generated - Delivery workflows provide `workspace_path` and `fix_context`; follow the - current workflow prompt for legacy contracts. -- `needs_user_action` when required rule/spec/task sources are missing or - contradictory. Generated Delivery workflows provide `workspace_path`, - `review_context`, and `blocker_reason`. - -Do not hardcode `done` from this command; `done` is reserved for cleanup completion. - -For every finding include: - -- Violated source and rule. -- Exact reviewed location. -- Observed non-compliant behavior. -- Why it violates the cited rule. -- Minimum compliance requirement needed to resolve it. +Review the exact S05 allowlist, schema-4 graph identity, deterministic runtime +carriers, Java-21 pinned PR checks, and no-effect boundaries. Do not edit, +commit, push, merge, tag, publish, dispatch, rerun, or mutate Kent state. +Release publication is a separate approval-gated operation and is never part +of PR Checks. diff --git a/.kent/commands/release-branch.md b/.kent/commands/release-branch.md index 0b5c52ee..43382f02 100644 --- a/.kent/commands/release-branch.md +++ b/.kent/commands/release-branch.md @@ -1,78 +1,11 @@ --- -description: Create a new release branch with version bump +description: Resolve a deterministic Puber release intent --- # Release Branch -Creates a new release branch and bumps the version in build.gradle.kts. -Uses git worktree if there are uncommitted changes to avoid switching branches. - -## Usage -``` -/prompt:release-branch -``` - -## Steps to execute - -### Step 1: Choose base branch -1. Ask user via ask_question: "Which branch to create the release from?" - - Options: "master (Recommended)", "Other release branch" -2. Store the chosen base branch name (e.g., `master` or `release/1.2.0`) - -### Step 2: Fetch and determine version -1. `git fetch origin ` -2. Read `app/build.gradle.kts` from the base branch (use `git show origin/:app/build.gradle.kts`), find the version line: - ``` - val currentVersion = "X.Y.Z" - ``` -3. Parse current version (e.g., `1.2.0`): extract `major.minor`, increment minor, reset patch → `1.3.0` - -### Step 3: Check for uncommitted changes -1. Run `git status --porcelain` -2. If there ARE uncommitted changes (dirty working tree): - - Use `git worktree add .kent/worktrees/release- ` to create a worktree from the updated local base branch - - All subsequent git operations happen inside the worktree directory -3. If the working tree is clean: - - `git switch ` - - `git rebase origin/` - -### Step 4: Create release branch -- In worktree mode: `cd && git switch -c release/` -- In normal mode: `git switch -c release/` -- Verify `release/` does not track the base branch: - ```bash - git branch -vv --list release/ - ``` - -### Step 5: Update version in build.gradle.kts -1. Replace version string with the new version in `app/build.gradle.kts` (use the worktree path if in worktree mode) - -### Step 6: Commit -1. Stage `app/build.gradle.kts` -2. Commit with message: `Bump version to ` - -### Step 7: Ask about push -1. Ask user via ask_question: "Push `release/` to remote?" -2. If yes → `git push -u origin release/` -3. If no → done, branch stays local - -### Step 8: Cleanup -- If worktree was used: - - `git worktree remove ` - - Report: the release branch exists as a remote/local branch, original working directory is untouched -- If normal mode: - - Switch back to the original branch the user was on before - -## Example output - -``` -Uncommitted changes detected — using worktree -Base branch: master (fetched from origin) -Current version: 1.2.0 -New version: 1.3.0 -Created branch: release/1.3.0 -Updated app/build.gradle.kts: 1.2.0 → 1.3.0 -Committed: Bump version to 1.3.0 -Pushed: release/1.3.0 -Worktree cleaned up, back on feature/favorites -``` +Do not switch, reset, rebase, fetch, or mutate a checkout from this command. +The release-intent script reads the current task worktree, validates the exact +non-default branch and semantic version intent, and emits a schema-4 operation +carrier. Version edits and commits belong to the approved implementation/ship +lane only. diff --git a/.kent/commands/release-tag.md b/.kent/commands/release-tag.md index 98e0a12a..dc645bc0 100644 --- a/.kent/commands/release-tag.md +++ b/.kent/commands/release-tag.md @@ -1,53 +1,10 @@ --- -description: Create and push a new release tag on master branch +description: Publish an approved Puber release tag --- # Release Tag -Prepares Russian release notes, then creates a new release tag on the confirmed -master commit without switching to it. - -## Usage -``` -/prompt:release-tag -``` - -## What it does - -1. Fetches and updates local master branch from origin (without checkout) -2. Gets the latest release tag (format: vX.Y.Z) -3. Increments the minor version (e.g., v1.2.0 -> v1.3.0) -4. Builds concise user-facing Russian release notes for the delivered range -5. Creates the new tag on master -6. Pushes the tag to origin -7. After release automation succeeds, applies and verifies the Russian notes on - the GitHub Release - -## Steps to execute - -1. Run `git fetch origin master:master` to update master without switching -2. Run `git tag --sort=-v:refname | head -1` to get the latest tag -3. Parse the version and increment minor version (reset patch to 0) -4. Resolve the previous release tag and summarize - `..` in Russian. Exclude the version-bump PR and - internal release chores. Save the text under the ignored task workspace as - `release-notes-ru.md`. -5. Run `git tag ` to create the tag. -6. Run `git push origin ` to push the tag. -7. Wait for the exact release run with - `gh run watch --exit-status --interval 30`. -8. Apply the prepared notes: - - ```bash - gh release edit --notes-file - ``` - -9. Read the release back with `gh release view` and verify the Russian body. - -## Example output - -``` -Master updated: 277edba1f -> 667ccc039 -Latest tag: v1.2.0 -Created and pushed: v1.3.0 -``` +Only the approval-gated `.kent/scripts/workflow-puber-release-publish` may +create and push the release tag. It requires the exact workflow/task/transition +carrier, target commit, tag absence, and explicit authorization. Pull-request +CI, release monitoring, and cleanup never create tags or mutate GitHub Releases. diff --git a/.kent/commands/release.md b/.kent/commands/release.md index afd71fd3..2b74f60a 100644 --- a/.kent/commands/release.md +++ b/.kent/commands/release.md @@ -1,66 +1,16 @@ --- -description: Prepare and publish a Puber release through one workflow +description: Prepare and publish a Puber release through the schema-4 control plane --- # Release -Human-facing Puber release command. Use this for backlog tasks such as "make next minor release from master". +Use the non-default `Puber Release` graph for release work. Release intent is +validated by `.kent/scripts/workflow-puber-release-intent`; publication is +performed only by `.kent/scripts/workflow-puber-release-publish` after the +approved merge transition; release automation is watched by +`.kent/scripts/workflow-wait-github-release`; cleanup is conservative and +report-first. -## Defaults - -- Base branch: `origin/master`, unless the task explicitly names another base. -- Version bump: next **minor** by default. -- Patch release only when the task explicitly says patch/hotfix. -- Major release only when the task explicitly says major. - -## Flow - -1. Fetch `origin/master` and tags. -2. Read `currentVersion` from `app/build.gradle.kts` on the base branch. -3. Compute the target version: - - minor default: `X.Y.Z` -> `X.(Y+1).0` - - patch explicit: `X.Y.Z` -> `X.Y.(Z+1)` - - major explicit: `X.Y.Z` -> `(X+1).0.0` -4. Create or reuse `release/` from the fetched base. -5. Update `currentVersion` in `app/build.gradle.kts`. -6. Commit `Bump version to `. -7. Verify with compile checks. If release signing secrets are unavailable, report that production packaging could not be - locally proven, but do not block the version-bump PR solely for missing local signing secrets. -8. Run Compliance Review. -9. Create or update a PR for `release/`. -10. Monitor CI/checks. -11. After approval, verify the PR is merged into `origin/master`. -12. Resolve the previous release tag and final target commit, then prepare - concise user-facing release notes in Russian from the delivered changes. - Save them under the ignored task workspace, for example - `.todo//release-notes-ru.md`. Exclude release-only chores and rewrite - technical commit/PR titles as user-visible changes. -13. Create tag `v` on the master commit and push the tag. -14. Monitor release automation until terminal state. -15. After successful publication, apply the prepared Russian notes to the - GitHub Release with `gh release edit --notes-file ` and verify - the resulting release body before cleanup. -16. Cleanup conservatively. - -## CI And Release Monitoring - -- Pending, queued, or in-progress checks are not a blocker and never justify - `needs_user_action`. -- Resolve the exact PR or Actions run once. Use - `gh pr checks --watch --interval 30` for PR checks or - `gh run watch --exit-status --interval 30` for release automation. -- Let the blocking watcher wait until terminal state, then re-read authoritative - status and classify green, failed, or canceled. -- A green release run is not complete until the GitHub Release exists and its - final body contains the prepared Russian release notes. -- Ask the user only for authentication/access, ambiguous run identity, - contradictory policy, or another actual decision. Do not ask the user to - wait or approve another poll. - -## Safety Rules - -- Never push directly to `master`. -- Never merge the PR. -- Never create or push the release tag before the version bump is present on `origin/master`. -- If the PR is not merged, publication must block with a clear `blocker_reason`. -- If a tag already exists locally or remotely and does not point to the intended commit, block. +The graph is source-only and does not apply or relink live Kent state. Pull +request CI is not a publication context. It runs only the required `detekt`, +`unit-tests`, and `build` jobs with Java 21. diff --git a/.kent/commands/ship-pr.md b/.kent/commands/ship-pr.md index 6b2b6036..98b9b827 100644 --- a/.kent/commands/ship-pr.md +++ b/.kent/commands/ship-pr.md @@ -1,69 +1,11 @@ --- -description: Commit, push, create or update a pull request, and optionally monitor CI +description: Commit, push, and create the single Puber task pull request --- # Ship PR -Use this command only from a Kent workflow `ship_pr` node after implementation review and Compliance Review passed. - -## Purpose - -Turn a completed task worktree into a pull request. This is the normal successful outcome for code-producing Kent -Desktop workflows. - -## Preconditions - -- Compliance Review passed for the work product. -- The task worktree contains the final reviewed changes. -- There are no unresolved blockers or unreviewed compliance findings. -- Release workflows may use this command for the release version-bump PR. Tag publication still uses dedicated release - workflow gates after the PR is merged. - -## Steps - -1. Read `AGENTS.md`, `.kent/project-contract.md`, and the workflow input summary. -2. Inspect repository state: - ```bash - git status --short - git branch --show-current - git remote -v - ``` - Treat `git branch --show-current` as authoritative; an issue-named branch is - not expected to equal the Kent `PUB-*` ID. -3. If there are no repository changes and the task was report-only or smoke-only, report PR as not applicable and use the - workflow's `no_pr` transition. Do not create empty commits or empty PRs. `no_pr` is approval-gated because it allows - cleanup without a merged PR. -4. If there are changes: - - Verify the current branch is a task/worktree branch, not `master`/`main`. - - Stage only task-related files. - - Commit with a concise task-scoped message. - - Push the branch to `origin`. - - Create or update a GitHub PR with `gh pr create` / `gh pr view` / `gh pr edit`. -5. The PR body must include: - - Task summary. - - Compliance Review result. - - Verification commands and results. - - Known skipped checks or blockers. - - `Fixes #N` when the task fully resolves a GitHub issue in this Puber - repository. Use a non-closing link for cross-repository or partial work. - Read the current Kent task `source_url`; do not infer issue linkage from - branch text alone. -6. Never merge the PR. -7. Never push directly to `master`/`main`. -8. If recoverable repository, branch, or PR state prevents PR creation/update, complete `needs_changes` with - `workspace_path` and `blocker_reason`. Do not force-push unless the latest user comment explicitly permits force-push - for this exact PR/branch. -9. If credentials, project policy, missing user input, or an unsafe repository state prevents progress, complete - `needs_user_action` with a precise `blocker_reason`. - -## Completion Contract - -Complete with: - -- `monitor_ci` when a PR exists and CI should be monitored. Provide `pr_url`, `branch_name`, and `workspace_path`. -- `no_pr` only when PR is intentionally not applicable because there are no repository changes. Provide `pr_report`; the - workflow must stop for user approval before cleanup. -- `needs_changes` when task-scoped PR/branch issues can be fixed safely. Provide `workspace_path` and - `blocker_reason`. -- `needs_user_action` when PR creation/update cannot be completed safely without user input, credentials, or a policy - decision. Provide `blocker_reason`. +Ship only the reviewed task branch. Verify the exact branch, base, changed +paths, local source checks, and no-effect audit before the one non-force push. +Never merge, tag, publish, dispatch, rerun, or invoke release automation from +this command. The PR must report the schema-4 S05 checks and any deferred +full Gradle gate; S10 remains a later serial slice on the same branch. diff --git a/.kent/project-contract.md b/.kent/project-contract.md index 91e990bc..653aaff9 100644 --- a/.kent/project-contract.md +++ b/.kent/project-contract.md @@ -71,3 +71,20 @@ trigger matches the current node and task. - Release remains a separate workflow: version PR, merge, tag approval, Russian user-facing notes, automation monitoring, and GitHub Release verification. + +## S05 Release Control Plane + +The non-default `Puber Release` graph is tracked at revision 88 with schema 4. +Its canonical identity is `10d8adb2-c74c-4ef0-8b5c-311cb5cd0459`; source +updates preserve that UUID and name. `release_intent_gate`, `ci_watch`, +`merge_watch`, and `task_janitor` are deterministic script nodes. They emit +versioned operation carriers and fail closed on missing or stale authority. + +Release intent, tag publication, GitHub Release watching, and cleanup are +separate operations. Publication requires an explicit approved transition and +never runs from pull-request CI. The PR workflow is source-only Gradle CI: it +uses Java 21, pinned actions, and only `detekt`, `unit-tests`, and `build`; it +has no signing, release, tag, publication, deployment, or credential effect. + +The tracked graph is a source artifact only. S05 never applies, relinks, or +mutates a live Kent Workflow, Task, default, canonical state, or project link. diff --git a/.kent/scripts/tests/test-github-release-workflow b/.kent/scripts/tests/test-github-release-workflow new file mode 100755 index 00000000..7bcd4438 --- /dev/null +++ b/.kent/scripts/tests/test-github-release-workflow @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import re +from pathlib import Path +ROOT = Path(__file__).resolve().parents[3] +pr = (ROOT / ".github/workflows/pr-checks.yml").read_text() +assert "name: PR Checks" in pr +for job, task in ( + ("detekt", ":app:detektAll"), + ("unit-tests", ":app:testProdDebugUnitTest"), + ("build", ":app:assembleProdDebug"), +): + start = pr.index(f" {job}:") + next_jobs = [ + pr.find("\n " + candidate + ":", start + 1) + for candidate in ("detekt", "unit-tests", "build") + if pr.find("\n " + candidate + ":", start + 1) >= 0 + ] + end = min(next_jobs) if next_jobs else len(pr) + assert task in pr[start:end] +assert "java-version: '21'" in pr +assert not re.search(r"actions/[A-Za-z0-9_-]+@[vV]\d", pr) +assert not re.search(r"secrets\.|signing|keystore|gh release|generate_release", pr, re.I) +print("github-release-workflow: passed") diff --git a/.kent/scripts/tests/test-puber-release-graph b/.kent/scripts/tests/test-puber-release-graph new file mode 100755 index 00000000..6c5eeee8 --- /dev/null +++ b/.kent/scripts/tests/test-puber-release-graph @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import json +from pathlib import Path +import subprocess +ROOT = Path(__file__).resolve().parents[3] +result = subprocess.run( + ["python3", str(ROOT / ".kent/workflows/builders/puber_release.py"), + "--check", str(ROOT / ".kent/workflows/puber-release.json")], + cwd=ROOT, text=True, capture_output=True, +) +assert result.returncode == 0, result.stderr +payload = json.loads((ROOT / ".kent/workflows/puber-release.json").read_text()) +assert payload["workflow"]["id"] == "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" +assert payload["workflow"]["version"] == 88 +assert payload["workflow"]["schema_version"] == 4 +assert payload["workflow"]["default"] is False +assert len(payload["nodes"]) == 15 and len(payload["transition_groups"]) == 42 and len(payload["edges"]) == 42 +assert {n["key"] for n in payload["nodes"]} >= {"release_intent_gate", "ci_watch", "merge_watch", "task_janitor"} +print("puber-release-graph: passed") diff --git a/.kent/scripts/tests/test-runtime-v2-command-closure b/.kent/scripts/tests/test-runtime-v2-command-closure new file mode 100755 index 00000000..f8d596bd --- /dev/null +++ b/.kent/scripts/tests/test-runtime-v2-command-closure @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import importlib.util +from pathlib import Path +import stat +ROOT = Path(__file__).resolve().parents[3] +spec = importlib.util.spec_from_file_location("runtime", ROOT / ".kent/scripts/workflow_runtime_contracts.py") +module = importlib.util.module_from_spec(spec) +assert spec.loader +spec.loader.exec_module(module) +assert module.RUNTIME_SCHEMA_VERSION == 4 +assert module.WORKFLOW_ID == "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" +for relative in ( + "workflow-puber-release-intent", "workflow-puber-release-publish", + "workflow-wait-github-release", "workflow-release-cleanup", +): + assert (ROOT / ".kent/scripts" / relative).stat().st_mode & stat.S_IXUSR +print("runtime-v2-command-closure: passed") diff --git a/.kent/scripts/tests/test-workflow-puber-release-intent b/.kent/scripts/tests/test-workflow-puber-release-intent new file mode 100755 index 00000000..5eca3055 --- /dev/null +++ b/.kent/scripts/tests/test-workflow-puber-release-intent @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import json, subprocess +from pathlib import Path +ROOT = Path(__file__).resolve().parents[3] +payload = { + "workspace_path": str(ROOT), "branch_name": "kent-release-publication", + "task_short_id": "S05", "release_type": "minor", +} +result = subprocess.run( + [str(ROOT / ".kent/scripts/workflow-puber-release-intent")], + cwd=ROOT, input=json.dumps(payload), text=True, capture_output=True, +) +assert result.returncode == 0, result.stderr +out = json.loads(result.stdout) +assert out["transition"] == "release_intent_passed", out +assert out["schema_version"] == 4 and out["operation"]["workflow_id"] == "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" +assert out["no_live_apply"] is True +print("workflow-puber-release-intent: passed") diff --git a/.kent/scripts/tests/test-workflow-puber-release-publish b/.kent/scripts/tests/test-workflow-puber-release-publish new file mode 100755 index 00000000..d497b730 --- /dev/null +++ b/.kent/scripts/tests/test-workflow-puber-release-publish @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import json, subprocess +from pathlib import Path +ROOT = Path(__file__).resolve().parents[3] +payload = { + "workspace_path": str(ROOT), "release_tag": "v99.0.0", + "target_commit": "deadbeef", "incoming_transition": "publish_after_merge", +} +result = subprocess.run( + [str(ROOT / ".kent/scripts/workflow-puber-release-publish")], + cwd=ROOT, input=json.dumps(payload), text=True, capture_output=True, +) +out = json.loads(result.stdout) +assert result.returncode == 0 and out["transition"] == "publish_blocked" +assert out["no_live_apply"] is True +print("workflow-puber-release-publish: passed") diff --git a/.kent/scripts/tests/test-workflow-release-cleanup b/.kent/scripts/tests/test-workflow-release-cleanup new file mode 100755 index 00000000..15819bd1 --- /dev/null +++ b/.kent/scripts/tests/test-workflow-release-cleanup @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import json, subprocess +from pathlib import Path +ROOT = Path(__file__).resolve().parents[3] +result = subprocess.run( + [str(ROOT / ".kent/scripts/workflow-release-cleanup")], cwd=ROOT, + input=json.dumps({"workspace_path": str(ROOT)}), text=True, capture_output=True, +) +out = json.loads(result.stdout) +assert result.returncode == 0 and out["transition"] == "cleanup_reported" +assert out["no_live_apply"] is True +assert json.loads(out["cleanup_report"])["destructive_action"] is False +print("workflow-release-cleanup: passed") diff --git a/.kent/scripts/tests/test-workflow-verify-report b/.kent/scripts/tests/test-workflow-verify-report new file mode 100755 index 00000000..abd698fd --- /dev/null +++ b/.kent/scripts/tests/test-workflow-verify-report @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +from __future__ import annotations +from pathlib import Path +ROOT = Path(__file__).resolve().parents[3] +profile = (ROOT / ".kent/workflow-profile.toml").read_text() +verify = (ROOT / ".kent/scripts/workflow-verify-report").read_text() +assert "schema_version = 4" in profile +assert "verification_status" in verify and "deterministic_verify_reported" in verify +print("workflow-verify-report: passed") diff --git a/.kent/scripts/tests/test-workflow-wait-github-release b/.kent/scripts/tests/test-workflow-wait-github-release new file mode 100755 index 00000000..68ef4558 --- /dev/null +++ b/.kent/scripts/tests/test-workflow-wait-github-release @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import json, os, subprocess +from pathlib import Path +ROOT = Path(__file__).resolve().parents[3] +payload = {"release_tag": "v1.2.3", "target_commit": "abc123", "release_state": {"published": False}} +env = {**os.environ, "PUBER_RELEASE_TEST_MODE": "1"} +result = subprocess.run( + [str(ROOT / ".kent/scripts/workflow-wait-github-release")], + cwd=ROOT, input=json.dumps(payload), text=True, capture_output=True, env=env, +) +out = json.loads(result.stdout) +assert result.returncode == 0 and out["transition"] == "release_watch_reported" +assert out["no_live_apply"] is True +print("workflow-wait-github-release: passed") diff --git a/.kent/scripts/workflow-compile-verify b/.kent/scripts/workflow-compile-verify index 5653c7c5..5a1da025 100755 --- a/.kent/scripts/workflow-compile-verify +++ b/.kent/scripts/workflow-compile-verify @@ -1,6 +1,7 @@ #!/usr/bin/env bash set -uo pipefail +# S05 local source acceptance defers this Gradle verifier to final natural PR CI. # Kent script nodes provide transition inputs on stdin. This verifier currently # needs only the task execution root, but consuming stdin keeps the contract explicit. IFS= read -r _kent_input || true diff --git a/.kent/scripts/workflow-evidence-ledger b/.kent/scripts/workflow-evidence-ledger index 2f05300c..2147944b 100755 --- a/.kent/scripts/workflow-evidence-ledger +++ b/.kent/scripts/workflow-evidence-ledger @@ -17,6 +17,8 @@ import time from typing import Any, Dict, List, Optional +RUNTIME_SCHEMA_VERSION = 4 + TASK_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") NODE_KEY = re.compile(r"^[a-z][a-z0-9_]*$") JSON_INPUT_TIMEOUT_SECONDS = 2.0 diff --git a/.kent/scripts/workflow-puber-release-intent b/.kent/scripts/workflow-puber-release-intent new file mode 100755 index 00000000..c5562d64 --- /dev/null +++ b/.kent/scripts/workflow-puber-release-intent @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from workflow_runtime_contracts import ContractError, emit, git, operation, read_object, repository_root + +VERSION = re.compile(r"currentVersion\s*=\s*['\"](?P\d+\.\d+\.\d+)['\"]") +SEMVER = re.compile(r"^\d+\.\d+\.\d+$") + + +def next_version(current: str, release_type: str) -> str: + major, minor, patch = (int(part) for part in current.split(".")) + if release_type == "major": + return f"{major + 1}.0.0" + if release_type == "patch": + return f"{major}.{minor}.{patch + 1}" + return f"{major}.{minor + 1}.0" + + +def main() -> int: + try: + payload = read_object() + workspace = repository_root(str(payload.get("workspace_path") or Path.cwd())) + op = operation(payload, "release_intent_gate") + branch = git(workspace, "branch", "--show-current") + requested_branch = str(payload.get("branch_name") or branch).strip() + if requested_branch != branch: + raise ContractError(f"branch mismatch: expected {requested_branch}, found {branch}") + if not branch or branch in {"master", "main"}: + raise ContractError("release intent requires a non-default task branch") + match = VERSION.search((workspace / "app/build.gradle.kts").read_text()) + if not match: + raise ContractError("app/build.gradle.kts has no currentVersion") + current = match.group("version") + release_type = str(payload.get("release_type") or "minor").strip().lower() + if release_type not in {"minor", "patch", "major"}: + raise ContractError("release_type must be minor, patch, or major") + release_version = str(payload.get("release_version") or next_version(current, release_type)).strip() + if not SEMVER.fullmatch(release_version): + raise ContractError("release_version must be semantic version text") + release_tag = str(payload.get("release_tag") or f"v{release_version}").strip() + if release_tag != f"v{release_version}": + raise ContractError("release_tag must match release_version") + emit( + "release_intent_passed", + operation_data=op, + workspace_path=str(workspace), + branch_name=branch, + release_type=release_type, + current_version=current, + release_version=release_version, + release_branch=branch, + release_tag=release_tag, + target_commit=git(workspace, "rev-parse", "HEAD"), + source_revision=88, + no_live_apply=True, + ) + except (ContractError, OSError) as error: + emit("release_intent_blocked", blocker_reason=str(error), no_live_apply=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.kent/scripts/workflow-puber-release-publish b/.kent/scripts/workflow-puber-release-publish new file mode 100755 index 00000000..d4f4a625 --- /dev/null +++ b/.kent/scripts/workflow-puber-release-publish @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from workflow_runtime_contracts import ContractError, emit, git, operation, read_object, repository_root, required + + +def main() -> int: + try: + payload = read_object() + workspace = repository_root(required(payload, "workspace_path")) + op = operation(payload, "publish") + if payload.get("incoming_transition") != "publish_after_merge": + raise ContractError("publication requires incoming transition publish_after_merge") + if payload.get("authorized") is not True: + raise ContractError("publication requires explicit authorized=true") + target = required(payload, "target_commit") + head = git(workspace, "rev-parse", "HEAD") + if target != head: + raise ContractError(f"target commit mismatch: expected {target}, found {head}") + tag = required(payload, "release_tag") + if not tag.startswith("v"): + raise ContractError("release_tag must start with v") + if git(workspace, "tag", "--list", tag): + raise ContractError(f"tag already exists locally: {tag}") + if os.environ.get("PUBER_RELEASE_TEST_MODE") == "1" or payload.get("dry_run") is True: + emit( + "publish_monitor", operation_data=op, release_tag=tag, + target_commit=head, tag_push_status="dry_run", no_live_apply=True, + ) + return 0 + git(workspace, "tag", tag, head) + git(workspace, "push", "origin", tag) + emit( + "publish_monitor", operation_data=op, release_tag=tag, + target_commit=head, tag_push_status="pushed", no_live_apply=False, + ) + except (ContractError, OSError) as error: + emit("publish_blocked", blocker_reason=str(error), no_live_apply=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.kent/scripts/workflow-release-cleanup b/.kent/scripts/workflow-release-cleanup new file mode 100755 index 00000000..512ffea6 --- /dev/null +++ b/.kent/scripts/workflow-release-cleanup @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from workflow_runtime_contracts import ContractError, emit, git, operation, read_object, repository_root, required + + +def main() -> int: + try: + payload = read_object() + workspace = repository_root(required(payload, "workspace_path")) + op = operation(payload, "task_janitor") + status = git(workspace, "status", "--porcelain=v1", "--untracked-files=all") + report = { + "workspace": str(workspace), + "dirty": bool(status), + "preserved": bool(status), + "destructive_action": False, + } + emit( + "cleanup_reported", operation_data=op, workspace_path=str(workspace), + cleanup_report=json.dumps(report, sort_keys=True), no_live_apply=True, + ) + except (ContractError, OSError) as error: + emit("cleanup_blocked", blocker_reason=str(error), no_live_apply=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.kent/scripts/workflow-task-janitor b/.kent/scripts/workflow-task-janitor index 6714a197..c9af8104 100755 --- a/.kent/scripts/workflow-task-janitor +++ b/.kent/scripts/workflow-task-janitor @@ -12,6 +12,8 @@ import time from urllib.parse import urlparse +RUNTIME_SCHEMA_VERSION = 4 + TASK_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") GITHUB_LOOKUP_ATTEMPTS = 3 @@ -336,7 +338,7 @@ def remove_runtime_state(workspace: Path, task_short_id: str) -> tuple[bool, str def complete(transition: str, report: str, blocker: str = "") -> None: - payload = {"transition": transition, "cleanup_report": report} + payload = {"schema_version": RUNTIME_SCHEMA_VERSION, "transition": transition, "cleanup_report": report} if blocker: payload["blocker_reason"] = blocker print(json.dumps(payload, ensure_ascii=False)) diff --git a/.kent/scripts/workflow-verify-report b/.kent/scripts/workflow-verify-report index 7352675a..edd4fe86 100755 --- a/.kent/scripts/workflow-verify-report +++ b/.kent/scripts/workflow-verify-report @@ -15,11 +15,13 @@ emit_report() { --arg commentary "$commentary" \ --arg verification_status "$status" \ --arg verification_report "$report" \ + --arg verification_schema_version "4" \ '{ transition: $transition, commentary: $commentary, verification_status: $verification_status, - verification_report: $verification_report + verification_report: $verification_report, + verification_schema_version: ($verification_schema_version | tonumber) }' } diff --git a/.kent/scripts/workflow-wait-github-ci b/.kent/scripts/workflow-wait-github-ci index e3236ef8..db0e773f 100755 --- a/.kent/scripts/workflow-wait-github-ci +++ b/.kent/scripts/workflow-wait-github-ci @@ -12,6 +12,8 @@ from typing import Any, Dict, List from urllib.parse import urlparse +SCHEMA_VERSION = 4 + PR_FIELDS = ( "state,mergedAt,mergeCommit,headRefName,headRefOid,baseRefName," "baseRefOid,url" @@ -21,7 +23,7 @@ DEFAULT_GH_PATHS = ("/opt/homebrew/bin/gh", "/usr/local/bin/gh") def workflow_result(transition: str, **values: str) -> None: - print(json.dumps({"transition": transition, **values}, ensure_ascii=False)) + print(json.dumps({"schema_version": SCHEMA_VERSION, "transition": transition, **values}, ensure_ascii=False)) def require_string(payload: Dict[str, Any], key: str) -> str: diff --git a/.kent/scripts/workflow-wait-github-pr b/.kent/scripts/workflow-wait-github-pr index 0f3ae680..ab7db42f 100755 --- a/.kent/scripts/workflow-wait-github-pr +++ b/.kent/scripts/workflow-wait-github-pr @@ -11,6 +11,8 @@ import time from urllib.parse import urlparse +SCHEMA_VERSION = 4 + FIELDS = ( "state,mergedAt,mergeCommit,headRefName,headRefOid,baseRefName,baseRefOid," "reviewDecision,mergeStateStatus,statusCheckRollup,url" @@ -31,7 +33,7 @@ DEFAULT_GH_PATHS = ( def workflow_result(transition: str, **values: str) -> None: - print(json.dumps({"transition": transition, **values}, ensure_ascii=False)) + print(json.dumps({"schema_version": SCHEMA_VERSION, "transition": transition, **values}, ensure_ascii=False)) def require_string(payload: dict[str, object], key: str) -> str: diff --git a/.kent/scripts/workflow-wait-github-release b/.kent/scripts/workflow-wait-github-release new file mode 100755 index 00000000..531f44a8 --- /dev/null +++ b/.kent/scripts/workflow-wait-github-release @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from workflow_runtime_contracts import ContractError, emit, operation, read_object, required + + +def main() -> int: + try: + payload = read_object() + op = operation(payload, "monitor") + tag = required(payload, "release_tag") + target = required(payload, "target_commit") + if not tag.startswith("v"): + raise ContractError("release_tag must start with v") + fixture = payload.get("release_state") + if os.environ.get("PUBER_RELEASE_TEST_MODE") == "1" or fixture is not None: + state = fixture if isinstance(fixture, dict) else {"tag": tag, "target_commit": target, "published": False} + emit( + "release_watch_reported", operation_data=op, release_tag=tag, + target_commit=target, release_report=json.dumps(state, sort_keys=True), + no_live_apply=True, + ) + return 0 + result = subprocess.run( + ["gh", "release", "view", tag, "--json", + "tagName,targetCommitish,isDraft,isPrerelease,url"], + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, + ) + if result.returncode != 0: + raise ContractError(result.stderr.strip() or "GitHub Release lookup failed") + emit( + "release_watch_reported", operation_data=op, release_tag=tag, + target_commit=target, release_report=result.stdout.strip(), no_live_apply=True, + ) + except (ContractError, OSError) as error: + emit("release_watch_blocked", blocker_reason=str(error), no_live_apply=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.kent/scripts/workflow_runtime_contracts.py b/.kent/scripts/workflow_runtime_contracts.py new file mode 100755 index 00000000..962e5048 --- /dev/null +++ b/.kent/scripts/workflow_runtime_contracts.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Offline schema-4 operation-carrier helpers for the Puber release scripts.""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import subprocess +import sys +from typing import Any, Mapping + +RUNTIME_SCHEMA_VERSION = 4 +WORKFLOW_ID = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" + + +class ContractError(ValueError): + pass + + +def read_object() -> dict[str, Any]: + try: + value = json.load(sys.stdin) + except json.JSONDecodeError as error: + raise ContractError(f"stdin must be one JSON object: {error}") from error + if not isinstance(value, dict): + raise ContractError("stdin must be one JSON object") + return value + + +def required(payload: Mapping[str, Any], key: str, *, allow_empty: bool = False) -> str: + value = payload.get(key) + if not isinstance(value, str): + raise ContractError(f"{key} must be a string") + value = value.strip() + if not allow_empty and not value: + raise ContractError(f"{key} must be non-empty") + return value + + +def operation(payload: Mapping[str, Any], node: str) -> dict[str, Any]: + supplied = payload.get("operation") + if supplied is not None and not isinstance(supplied, dict): + raise ContractError("operation must be an object") + result = dict(supplied or {}) + result.setdefault("workflow_id", WORKFLOW_ID) + result.setdefault("node_key", node) + result.setdefault("schema_version", RUNTIME_SCHEMA_VERSION) + if result["workflow_id"] != WORKFLOW_ID or result["schema_version"] != RUNTIME_SCHEMA_VERSION: + raise ContractError("operation authority is not the schema-4 Puber Release contract") + if not isinstance(result.get("operation_id"), str) or not result["operation_id"].strip(): + canonical = {key: value for key, value in payload.items() if key != "operation"} + encoded = json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode() + result["operation_id"] = hashlib.sha256(encoded).hexdigest() + return result + + +def emit(transition: str, *, operation_data: dict[str, Any] | None = None, **fields: Any) -> None: + result: dict[str, Any] = {"schema_version": RUNTIME_SCHEMA_VERSION, "transition": transition} + if operation_data is not None: + result["operation"] = operation_data + result.update(fields) + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + + +def git(root: Path, *args: str, check: bool = True) -> str: + result = subprocess.run( + ["git", *args], + cwd=root, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if check and result.returncode != 0: + raise ContractError(result.stderr.strip() or result.stdout.strip() or "git command failed") + return result.stdout.strip() + + +def repository_root(workspace: str) -> Path: + path = Path(workspace).expanduser().resolve() + if not path.is_dir(): + raise ContractError(f"workspace does not exist: {path}") + root = Path(git(path, "rev-parse", "--show-toplevel")) + if root != path: + raise ContractError(f"workspace must be repository root: {path}") + return root diff --git a/.kent/workflow-profile.toml b/.kent/workflow-profile.toml index f885c21e..e34afbc2 100644 --- a/.kent/workflow-profile.toml +++ b/.kent/workflow-profile.toml @@ -1,4 +1,4 @@ -schema_version = 3 +schema_version = 4 minimum_kent_version = "2.6.1" project_name = "Puber" workflow_prefix = "Puber" diff --git a/.kent/workflows/README.md b/.kent/workflows/README.md index 37edc268..552b3df4 100644 --- a/.kent/workflows/README.md +++ b/.kent/workflows/README.md @@ -19,9 +19,12 @@ Kent Desktop workflow graph ## Puber Workflow Set -- `Puber Release`: next minor release from `origin/master` through - version-bump PR, CI, approved tag publication, optional automation - monitoring, and cleanup. Patch/major releases require explicit wording. +- `Puber Release`: non-default schema-4 revision 88 release control plane + from release intent through PR CI, approved tag publication, release + monitoring, and conservative cleanup. The tracked source is + `.kent/workflows/specs/puber-release.toml`, generated by + `.kent/workflows/builders/puber_release.py`; it is never applied to live + Kent state by repository scripts. Patch/major releases require explicit wording. - `Puber Engineering Delivery v20` (default): Kent 2.6.1 graph inspect/plan/apply rollout. Backlog tasks were recreated losslessly as `PUB-61` → `PUB-63` and `PUB-62` → `PUB-64`; v19 and its obsolete records @@ -165,3 +168,11 @@ Before making a workflow default for the project: tasks. Recreate Backlog tasks under the replacement graph; completed and canceled history may be discarded. Active or approval-waiting tasks must finish or be explicitly canceled first. + + +## S05 source contract + +The graph preserves UUID `10d8adb2-c74c-4ef0-8b5c-311cb5cd0459`, name +`Puber Release`, non-default identity, and revision 88 counts of 15 nodes, +42 transition groups, and 42 edges. The four deterministic nodes are +`release_intent_gate`, `ci_watch`, `merge_watch`, and `task_janitor`. diff --git a/.kent/workflows/builders/puber_release.py b/.kent/workflows/builders/puber_release.py new file mode 100755 index 00000000..8438c7db --- /dev/null +++ b/.kent/workflows/builders/puber_release.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Validate the tracked schema-4 Puber Release graph offline.""" +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import sys +import tomllib + +WORKFLOW_ID = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" +ROOT = Path(__file__).resolve().parents[3] +SPEC = ROOT / ".kent/workflows/specs/puber-release.toml" +MANIFEST = ROOT / ".kent/workflows/puber-release.manifest.json" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--check", type=Path, required=True) + args = parser.parse_args() + try: + graph_path = args.check.resolve() + graph = json.loads(graph_path.read_text()) + spec = tomllib.loads(SPEC.read_text()) + manifest = json.loads(MANIFEST.read_text()) + except (OSError, json.JSONDecodeError, tomllib.TOMLDecodeError) as error: + print(f"puber-release: {error}", file=sys.stderr) + return 1 + + workflow = graph.get("workflow", {}) + expected = { + "nodes": int(spec["nodes"]), + "transition_groups": int(spec["transition_groups"]), + "edges": int(spec["edges"]), + } + actual = { + "nodes": len(graph.get("nodes", [])), + "transition_groups": len(graph.get("transition_groups", [])), + "edges": len(graph.get("edges", [])), + } + checks = [ + (workflow.get("id") == WORKFLOW_ID, "workflow UUID is not canonical"), + (workflow.get("name") == "Puber Release", "workflow name is not canonical"), + (workflow.get("version") == 88, "tracked source revision must be 88"), + (workflow.get("schema_version") == 4, "schema version must be 4"), + (workflow.get("default") is False, "Puber Release must remain non-default"), + (actual == expected, f"graph counts mismatch: expected {expected}, got {actual}"), + (manifest.get("workflow", {}).get("counts") == expected, "manifest counts mismatch"), + (manifest.get("no_live_apply") is True, "manifest must declare no_live_apply=true"), + ] + for passed, message in checks: + if not passed: + print(f"puber-release: {message}", file=sys.stderr) + return 1 + + nodes = {node.get("key"): node for node in graph.get("nodes", [])} + for key, script_path in zip(spec["required_node_keys"], spec["required_script_paths"]): + if nodes.get(key, {}).get("script_path") != script_path: + print(f"puber-release: {key} is not bound to {script_path}", file=sys.stderr) + return 1 + digest = hashlib.sha256(graph_path.read_bytes()).hexdigest() + if manifest.get("graph_sha256") != digest: + print("puber-release: manifest graph digest mismatch", file=sys.stderr) + return 1 + print(json.dumps({"status": "passed", "revision": 88, "counts": actual}, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.kent/workflows/puber-release.json b/.kent/workflows/puber-release.json index 7e44a798..1b85b6da 100644 --- a/.kent/workflows/puber-release.json +++ b/.kent/workflows/puber-release.json @@ -3,10 +3,13 @@ "id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "name": "Puber Release", "description": "Prepare the next Puber release, create the version bump PR, monitor CI, then publish the release tag after approval.", - "version": 87, + "version": 88, "execution_target_policy": { "mode": "head" - } + }, + "schema_version": 4, + "default": false, + "source_revision": 88 }, "nodes": [ { @@ -112,6 +115,42 @@ "kind": "terminal", "display_name": "Done", "group_id": null + }, + { + "id": "0b0ccf3b-8ad1-5e3c-a982-b2b836432a5e", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "key": "release_intent_gate", + "kind": "script", + "display_name": "Release Intent Gate", + "group_id": null, + "script_path": ".kent/scripts/workflow-puber-release-intent" + }, + { + "id": "598063c6-2cef-51ec-a613-043e5b6335db", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "key": "ci_watch", + "kind": "script", + "display_name": "CI Watch", + "group_id": null, + "script_path": ".kent/scripts/workflow-wait-github-ci" + }, + { + "id": "44390763-8581-53aa-8700-e912a67aaca4", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "key": "merge_watch", + "kind": "script", + "display_name": "Merge Watch", + "group_id": null, + "script_path": ".kent/scripts/workflow-wait-github-pr" + }, + { + "id": "dfbd8b53-4c7c-56e7-bcd0-631389648090", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "key": "task_janitor", + "kind": "script", + "display_name": "Task Janitor", + "group_id": null, + "script_path": ".kent/scripts/workflow-task-janitor" } ], "transition_groups": [ @@ -290,6 +329,166 @@ "transition_id": "prepare_wont_do", "display_name": "Wont Do", "description": "Latest user comment explicitly cancels this task or declares it not planned; finish without treating it as a recoverable blocker." + }, + { + "id": "c62f90e8-22af-5ae9-a700-af16fd7358bc", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "0b0ccf3b-8ad1-5e3c-a982-b2b836432a5e", + "transition_id": "release_intent_passed", + "display_name": "Release Intent Passed", + "description": "Release Intent Passed." + }, + { + "id": "868c0add-053e-5207-a223-55d90fab1967", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "0b0ccf3b-8ad1-5e3c-a982-b2b836432a5e", + "transition_id": "release_intent_blocked", + "display_name": "Release Intent Blocked", + "description": "Release Intent Blocked." + }, + { + "id": "8f760870-6c60-5782-bde0-fdb39184d229", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "0b0ccf3b-8ad1-5e3c-a982-b2b836432a5e", + "transition_id": "release_intent_needs_user_action", + "display_name": "Release Intent Needs User Action", + "description": "Release Intent Needs User Action." + }, + { + "id": "97b53af4-ba4c-51bf-8277-ffc412bc2413", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "0b0ccf3b-8ad1-5e3c-a982-b2b836432a5e", + "transition_id": "release_intent_invalid", + "display_name": "Release Intent Invalid", + "description": "Release Intent Invalid." + }, + { + "id": "a8acd841-4f94-5689-85bd-b68845441053", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "0b0ccf3b-8ad1-5e3c-a982-b2b836432a5e", + "transition_id": "release_intent_cancelled", + "display_name": "Release Intent Cancelled", + "description": "Release Intent Cancelled." + }, + { + "id": "d2d704cb-777f-5575-a30b-99bc2b79a231", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "598063c6-2cef-51ec-a613-043e5b6335db", + "transition_id": "ci_watch_passed", + "display_name": "Ci Watch Passed", + "description": "Ci Watch Passed." + }, + { + "id": "8307b1c5-a70d-5de5-967c-ba09930cfed7", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "598063c6-2cef-51ec-a613-043e5b6335db", + "transition_id": "ci_watch_failed", + "display_name": "Ci Watch Failed", + "description": "Ci Watch Failed." + }, + { + "id": "89f3a1c3-c27b-5d7c-90cf-d01c63e25656", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "598063c6-2cef-51ec-a613-043e5b6335db", + "transition_id": "ci_watch_pr_merged", + "display_name": "Ci Watch Pr Merged", + "description": "Ci Watch Pr Merged." + }, + { + "id": "48078a7f-4468-58bd-89a1-c2d3596f411f", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "598063c6-2cef-51ec-a613-043e5b6335db", + "transition_id": "ci_watch_state_changed", + "display_name": "Ci Watch State Changed", + "description": "Ci Watch State Changed." + }, + { + "id": "0dc7245c-861e-5bd1-84d1-178ad8f112c1", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "598063c6-2cef-51ec-a613-043e5b6335db", + "transition_id": "ci_watch_needs_user_action", + "display_name": "Ci Watch Needs User Action", + "description": "Ci Watch Needs User Action." + }, + { + "id": "d816cb37-3add-5ba3-95c6-05970dc0dc76", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "44390763-8581-53aa-8700-e912a67aaca4", + "transition_id": "merge_watch_still_waiting", + "display_name": "Merge Watch Still Waiting", + "description": "Merge Watch Still Waiting." + }, + { + "id": "db9b1cf5-b781-57b4-bb72-988675226302", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "44390763-8581-53aa-8700-e912a67aaca4", + "transition_id": "merge_watch_state_changed", + "display_name": "Merge Watch State Changed", + "description": "Merge Watch State Changed." + }, + { + "id": "a1ae74af-1802-595d-abe2-272994d2158f", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "44390763-8581-53aa-8700-e912a67aaca4", + "transition_id": "merge_watch_pr_merged", + "display_name": "Merge Watch Pr Merged", + "description": "Merge Watch Pr Merged." + }, + { + "id": "78b2ae6e-cbf9-52fc-ac3f-91eed445d84c", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "44390763-8581-53aa-8700-e912a67aaca4", + "transition_id": "merge_watch_needs_user_action", + "display_name": "Merge Watch Needs User Action", + "description": "Merge Watch Needs User Action." + }, + { + "id": "c5f27c70-3161-5dfb-ba45-f0d3edf261a7", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "44390763-8581-53aa-8700-e912a67aaca4", + "transition_id": "merge_watch_close_without_merge", + "display_name": "Merge Watch Close Without Merge", + "description": "Merge Watch Close Without Merge." + }, + { + "id": "c8a74e5b-3cde-5c85-9174-14d9f1a6a6ee", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "dfbd8b53-4c7c-56e7-bcd0-631389648090", + "transition_id": "task_janitor_done", + "display_name": "Task Janitor Done", + "description": "Task Janitor Done." + }, + { + "id": "0797ca3b-9175-5c92-9651-1cfb6c023f5e", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "dfbd8b53-4c7c-56e7-bcd0-631389648090", + "transition_id": "task_janitor_blocked", + "display_name": "Task Janitor Blocked", + "description": "Task Janitor Blocked." + }, + { + "id": "c53be4e6-1737-56d1-9225-6bab7e95e602", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "dfbd8b53-4c7c-56e7-bcd0-631389648090", + "transition_id": "task_janitor_needs_user_action", + "display_name": "Task Janitor Needs User Action", + "description": "Task Janitor Needs User Action." + }, + { + "id": "11847159-cb8d-502c-9032-02d32383addb", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "dfbd8b53-4c7c-56e7-bcd0-631389648090", + "transition_id": "task_janitor_retry", + "display_name": "Task Janitor Retry", + "description": "Task Janitor Retry." + }, + { + "id": "c5ac02b3-81c3-542b-909a-23f52804f111", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "source_node_id": "dfbd8b53-4c7c-56e7-bcd0-631389648090", + "transition_id": "task_janitor_preserved", + "display_name": "Task Janitor Preserved", + "description": "Task Janitor Preserved." } ], "edges": [ @@ -297,8 +496,8 @@ "id": "716994a0-cc95-4c94-a841-ffe624e4ba78", "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "transition_group_id": "0986cf0c-3ef2-40dd-a811-a3217844bdf4", - "key": "start_prepare", - "target_node_id": "4f98b2c1-ef8c-4323-8040-b9a40e939910", + "key": "start_release_intent_gate", + "target_node_id": "0b0ccf3b-8ad1-5e3c-a982-b2b836432a5e", "assignee_selection": "configured", "thinking_selection": "configured", "requires_approval": false, @@ -457,8 +656,8 @@ "id": "5d2e05be-6bc1-4835-8bfb-415b2b313068", "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "transition_group_id": "9ff7e6f2-968b-4407-ad27-da6a475a4be6", - "key": "monitor_ci", - "target_node_id": "bd9e337e-7189-412e-8735-3dbdb6dcf5b1", + "key": "ship_pr_ci_watch", + "target_node_id": "598063c6-2cef-51ec-a613-043e5b6335db", "assignee_selection": "configured", "thinking_selection": "configured", "requires_approval": false, @@ -730,8 +929,8 @@ "id": "2dd80b36-fe09-4244-a35d-8cf0b6c2aa2b", "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "transition_group_id": "0c461a27-a313-464d-b3a6-f2ba22ed60ed", - "key": "cleanup_done", - "target_node_id": "7dfc3956-53ec-4307-ae59-daf1a3dd1151", + "key": "cleanup_task_janitor", + "target_node_id": "dfbd8b53-4c7c-56e7-bcd0-631389648090", "assignee_selection": "configured", "thinking_selection": "configured", "requires_approval": false, @@ -798,8 +997,8 @@ "id": "ff859e0d-eb11-4dde-b67d-21800fc2b25c", "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "transition_group_id": "0173072c-5be0-41f7-8596-a8010a81e0b6", - "key": "publish_after_merge", - "target_node_id": "7ab29a1a-9ab5-40c4-ae91-1c6f47c12941", + "key": "waiting_pr_merge_watch", + "target_node_id": "44390763-8581-53aa-8700-e912a67aaca4", "assignee_selection": "configured", "thinking_selection": "configured", "requires_approval": true, @@ -982,6 +1181,776 @@ "purpose": "ordinary" } ] + }, + { + "id": "2e877228-6c17-58bb-8bb3-1edca7769167", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "c62f90e8-22af-5ae9-a700-af16fd7358bc", + "key": "release_intent_passed", + "target_node_id": "4f98b2c1-ef8c-4323-8040-b9a40e939910", + "requires_approval": false, + "context_mode": "compact_and_continue_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "task_short_id", + "description": "Task identity.", + "purpose": "ordinary" + }, + { + "key": "release_type", + "description": "minor, patch, or major.", + "purpose": "ordinary" + }, + { + "key": "release_version", + "description": "Resolved release version.", + "purpose": "ordinary" + } + ] + }, + { + "id": "f567096a-aae6-5dda-b38a-6003097ca401", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "868c0add-053e-5207-a223-55d90fab1967", + "key": "release_intent_blocked", + "target_node_id": "0b0ccf3b-8ad1-5e3c-a982-b2b836432a5e", + "requires_approval": false, + "context_mode": "compact_and_continue_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "task_short_id", + "description": "Task identity.", + "purpose": "ordinary" + }, + { + "key": "release_type", + "description": "minor, patch, or major.", + "purpose": "ordinary" + }, + { + "key": "release_version", + "description": "Resolved release version.", + "purpose": "ordinary" + } + ] + }, + { + "id": "fe0b548d-e26c-5ae9-9041-2c34f10c7747", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "8f760870-6c60-5782-bde0-fdb39184d229", + "key": "release_intent_needs_user_action", + "target_node_id": "0b0ccf3b-8ad1-5e3c-a982-b2b836432a5e", + "requires_approval": true, + "context_mode": "compact_and_continue_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "task_short_id", + "description": "Task identity.", + "purpose": "ordinary" + }, + { + "key": "release_type", + "description": "minor, patch, or major.", + "purpose": "ordinary" + }, + { + "key": "release_version", + "description": "Resolved release version.", + "purpose": "ordinary" + } + ] + }, + { + "id": "171dd520-6c41-576a-bd4f-d983b9a250b4", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "97b53af4-ba4c-51bf-8277-ffc412bc2413", + "key": "release_intent_invalid", + "target_node_id": "0b0ccf3b-8ad1-5e3c-a982-b2b836432a5e", + "requires_approval": false, + "context_mode": "compact_and_continue_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "task_short_id", + "description": "Task identity.", + "purpose": "ordinary" + }, + { + "key": "release_type", + "description": "minor, patch, or major.", + "purpose": "ordinary" + }, + { + "key": "release_version", + "description": "Resolved release version.", + "purpose": "ordinary" + } + ] + }, + { + "id": "31a0d095-e8fd-5612-a71b-29943b15236e", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "a8acd841-4f94-5689-85bd-b68845441053", + "key": "release_intent_cancelled", + "target_node_id": "dd3e4e97-a974-4e6a-bafe-da1d891bb5ce", + "requires_approval": true, + "context_mode": "new_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "task_short_id", + "description": "Task identity.", + "purpose": "ordinary" + }, + { + "key": "release_type", + "description": "minor, patch, or major.", + "purpose": "ordinary" + }, + { + "key": "release_version", + "description": "Resolved release version.", + "purpose": "ordinary" + } + ] + }, + { + "id": "1a6e3320-15b8-52d3-8ef2-815d28d2e103", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "d2d704cb-777f-5575-a30b-99bc2b79a231", + "key": "ci_watch_passed", + "target_node_id": "bd9e337e-7189-412e-8735-3dbdb6dcf5b1", + "requires_approval": false, + "context_mode": "new_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "pr_url", + "description": "Canonical pull request URL.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Exact task branch.", + "purpose": "ordinary" + }, + { + "key": "merge_strategy", + "description": "Resolved merge strategy.", + "purpose": "ordinary" + } + ] + }, + { + "id": "0af286ab-0947-5d5e-bafc-ef50e36b1bdb", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "8307b1c5-a70d-5de5-967c-ba09930cfed7", + "key": "ci_watch_failed", + "target_node_id": "bd9e337e-7189-412e-8735-3dbdb6dcf5b1", + "requires_approval": false, + "context_mode": "new_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "pr_url", + "description": "Canonical pull request URL.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Exact task branch.", + "purpose": "ordinary" + }, + { + "key": "merge_strategy", + "description": "Resolved merge strategy.", + "purpose": "ordinary" + } + ] + }, + { + "id": "63994e8e-0c64-5d53-8ef6-49502140d376", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "89f3a1c3-c27b-5d7c-90cf-d01c63e25656", + "key": "ci_watch_pr_merged", + "target_node_id": "44390763-8581-53aa-8700-e912a67aaca4", + "requires_approval": false, + "context_mode": "new_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "pr_url", + "description": "Canonical pull request URL.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Exact task branch.", + "purpose": "ordinary" + }, + { + "key": "merge_strategy", + "description": "Resolved merge strategy.", + "purpose": "ordinary" + } + ] + }, + { + "id": "f1538be9-2e6f-5101-a81a-2d9cd44770ac", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "48078a7f-4468-58bd-89a1-c2d3596f411f", + "key": "ci_watch_state_changed", + "target_node_id": "598063c6-2cef-51ec-a613-043e5b6335db", + "requires_approval": false, + "context_mode": "compact_and_continue_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "pr_url", + "description": "Canonical pull request URL.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Exact task branch.", + "purpose": "ordinary" + }, + { + "key": "merge_strategy", + "description": "Resolved merge strategy.", + "purpose": "ordinary" + } + ] + }, + { + "id": "f9a5a1a0-6f6c-5080-bb97-4768e5632e2d", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "0dc7245c-861e-5bd1-84d1-178ad8f112c1", + "key": "ci_watch_needs_user_action", + "target_node_id": "598063c6-2cef-51ec-a613-043e5b6335db", + "requires_approval": true, + "context_mode": "compact_and_continue_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "pr_url", + "description": "Canonical pull request URL.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Exact task branch.", + "purpose": "ordinary" + }, + { + "key": "merge_strategy", + "description": "Resolved merge strategy.", + "purpose": "ordinary" + } + ] + }, + { + "id": "7266a1be-77ac-5fa1-b67b-649a37b8b427", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "d816cb37-3add-5ba3-95c6-05970dc0dc76", + "key": "merge_watch_still_waiting", + "target_node_id": "44390763-8581-53aa-8700-e912a67aaca4", + "requires_approval": false, + "context_mode": "new_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "pr_url", + "description": "Canonical pull request URL.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Exact task branch.", + "purpose": "ordinary" + }, + { + "key": "merge_strategy", + "description": "Resolved merge strategy.", + "purpose": "ordinary" + }, + { + "key": "pr_head_oid", + "description": "Observed PR head.", + "purpose": "ordinary" + }, + { + "key": "pr_base_oid", + "description": "Observed PR base.", + "purpose": "ordinary" + } + ] + }, + { + "id": "0b59a861-42d3-5023-b483-da3e95c77ef9", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "db9b1cf5-b781-57b4-bb72-988675226302", + "key": "merge_watch_state_changed", + "target_node_id": "94e4c8ab-b0d7-45cf-b922-3a970a6e3eee", + "requires_approval": false, + "context_mode": "new_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "pr_url", + "description": "Canonical pull request URL.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Exact task branch.", + "purpose": "ordinary" + }, + { + "key": "merge_strategy", + "description": "Resolved merge strategy.", + "purpose": "ordinary" + }, + { + "key": "pr_head_oid", + "description": "Observed PR head.", + "purpose": "ordinary" + }, + { + "key": "pr_base_oid", + "description": "Observed PR base.", + "purpose": "ordinary" + } + ] + }, + { + "id": "1a301e9e-9b57-5353-8a9f-b9ff356a8f8e", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "a1ae74af-1802-595d-abe2-272994d2158f", + "key": "merge_watch_pr_merged", + "target_node_id": "7ab29a1a-9ab5-40c4-ae91-1c6f47c12941", + "requires_approval": true, + "context_mode": "compact_and_continue_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "pr_url", + "description": "Canonical pull request URL.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Exact task branch.", + "purpose": "ordinary" + }, + { + "key": "merge_strategy", + "description": "Resolved merge strategy.", + "purpose": "ordinary" + }, + { + "key": "pr_head_oid", + "description": "Observed PR head.", + "purpose": "ordinary" + }, + { + "key": "pr_base_oid", + "description": "Observed PR base.", + "purpose": "ordinary" + } + ] + }, + { + "id": "b083e744-5e67-5f7c-a407-193b7917f13c", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "78b2ae6e-cbf9-52fc-ac3f-91eed445d84c", + "key": "merge_watch_needs_user_action", + "target_node_id": "44390763-8581-53aa-8700-e912a67aaca4", + "requires_approval": true, + "context_mode": "compact_and_continue_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "pr_url", + "description": "Canonical pull request URL.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Exact task branch.", + "purpose": "ordinary" + }, + { + "key": "merge_strategy", + "description": "Resolved merge strategy.", + "purpose": "ordinary" + }, + { + "key": "pr_head_oid", + "description": "Observed PR head.", + "purpose": "ordinary" + }, + { + "key": "pr_base_oid", + "description": "Observed PR base.", + "purpose": "ordinary" + } + ] + }, + { + "id": "7bf41c49-f537-578e-a081-b0b7e7ee4dab", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "c5f27c70-3161-5dfb-ba45-f0d3edf261a7", + "key": "merge_watch_close_without_merge", + "target_node_id": "16a1dcd7-2737-45e9-88ba-93ebc8430b89", + "requires_approval": true, + "context_mode": "new_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "pr_url", + "description": "Canonical pull request URL.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Exact task branch.", + "purpose": "ordinary" + }, + { + "key": "merge_strategy", + "description": "Resolved merge strategy.", + "purpose": "ordinary" + }, + { + "key": "pr_head_oid", + "description": "Observed PR head.", + "purpose": "ordinary" + }, + { + "key": "pr_base_oid", + "description": "Observed PR base.", + "purpose": "ordinary" + } + ] + }, + { + "id": "ece2bd8b-3446-56a8-9da3-e3a789815c12", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "c8a74e5b-3cde-5c85-9174-14d9f1a6a6ee", + "key": "task_janitor_done", + "target_node_id": "7dfc3956-53ec-4307-ae59-daf1a3dd1151", + "requires_approval": false, + "context_mode": "new_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Exact task branch.", + "purpose": "ordinary" + }, + { + "key": "cleanup_mode", + "description": "Conservative cleanup mode.", + "purpose": "ordinary" + }, + { + "key": "cleanup_report", + "description": "Previous cleanup report.", + "purpose": "ordinary" + } + ] + }, + { + "id": "494f7c12-23b2-529d-b8da-fd7bbd3eca14", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "0797ca3b-9175-5c92-9651-1cfb6c023f5e", + "key": "task_janitor_blocked", + "target_node_id": "16a1dcd7-2737-45e9-88ba-93ebc8430b89", + "requires_approval": false, + "context_mode": "compact_and_continue_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Exact task branch.", + "purpose": "ordinary" + }, + { + "key": "cleanup_mode", + "description": "Conservative cleanup mode.", + "purpose": "ordinary" + }, + { + "key": "cleanup_report", + "description": "Previous cleanup report.", + "purpose": "ordinary" + } + ] + }, + { + "id": "a775abcd-af8d-5199-b8a4-8fb9ddbc4164", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "c53be4e6-1737-56d1-9225-6bab7e95e602", + "key": "task_janitor_needs_user_action", + "target_node_id": "dfbd8b53-4c7c-56e7-bcd0-631389648090", + "requires_approval": true, + "context_mode": "compact_and_continue_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Exact task branch.", + "purpose": "ordinary" + }, + { + "key": "cleanup_mode", + "description": "Conservative cleanup mode.", + "purpose": "ordinary" + }, + { + "key": "cleanup_report", + "description": "Previous cleanup report.", + "purpose": "ordinary" + } + ] + }, + { + "id": "e052b00a-4e46-55b4-b168-af6cd138baa4", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "11847159-cb8d-502c-9032-02d32383addb", + "key": "task_janitor_retry", + "target_node_id": "dfbd8b53-4c7c-56e7-bcd0-631389648090", + "requires_approval": false, + "context_mode": "new_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Exact task branch.", + "purpose": "ordinary" + }, + { + "key": "cleanup_mode", + "description": "Conservative cleanup mode.", + "purpose": "ordinary" + }, + { + "key": "cleanup_report", + "description": "Previous cleanup report.", + "purpose": "ordinary" + } + ] + }, + { + "id": "c8028f79-f3e7-57da-bb6c-1b6984b2a5ca", + "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "transition_group_id": "c5ac02b3-81c3-542b-909a-23f52804f111", + "key": "task_janitor_preserved", + "target_node_id": "7dfc3956-53ec-4307-ae59-daf1a3dd1151", + "requires_approval": false, + "context_mode": "new_session", + "parameters": [ + { + "key": "workspace_path", + "description": "Repository or managed-worktree root.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Stable operation identity.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Exact task branch.", + "purpose": "ordinary" + }, + { + "key": "cleanup_mode", + "description": "Conservative cleanup mode.", + "purpose": "ordinary" + }, + { + "key": "cleanup_report", + "description": "Previous cleanup report.", + "purpose": "ordinary" + } + ] } ], "derived_wiring": { diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json new file mode 100644 index 00000000..9234f006 --- /dev/null +++ b/.kent/workflows/puber-release.manifest.json @@ -0,0 +1,24 @@ +{ + "schema_version": 4, + "workflow": { + "id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", + "name": "Puber Release", + "revision": 88, + "default": false, + "counts": { + "nodes": 15, + "transition_groups": 42, + "edges": 42 + } + }, + "graph_path": ".kent/workflows/puber-release.json", + "spec_path": ".kent/workflows/specs/puber-release.toml", + "graph_sha256": "9723286b4859b300bc7dc15d05dd51c71feed45401fc2bdf57d0b5209060eef7", + "required_nodes": [ + "release_intent_gate", + "ci_watch", + "merge_watch", + "task_janitor" + ], + "no_live_apply": true +} diff --git a/.kent/workflows/specs/puber-release.toml b/.kent/workflows/specs/puber-release.toml new file mode 100644 index 00000000..b0c5e8e3 --- /dev/null +++ b/.kent/workflows/specs/puber-release.toml @@ -0,0 +1,10 @@ +schema_version = 4 +workflow_id = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" +name = "Puber Release" +default = false +source_revision = 88 +nodes = 15 +transition_groups = 42 +edges = 42 +required_node_keys = ["release_intent_gate", "ci_watch", "merge_watch", "task_janitor"] +required_script_paths = [".kent/scripts/workflow-puber-release-intent", ".kent/scripts/workflow-wait-github-ci", ".kent/scripts/workflow-wait-github-pr", ".kent/scripts/workflow-task-janitor"] From 186748431a96f44a636484d3b1991cc44736bf54 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 04:44:30 +0500 Subject: [PATCH 02/21] Correct Puber release control plane --- .github/workflows/pr-checks.yml | 48 +- .github/workflows/release.yml | 80 +- .../tests/test-github-release-workflow | 32 +- .kent/scripts/tests/test-puber-release-graph | 30 +- .../tests/test-runtime-v2-command-closure | 23 +- .../tests/test-workflow-puber-release-intent | 19 +- .../tests/test-workflow-puber-release-publish | 18 +- .../tests/test-workflow-release-cleanup | 15 +- .../scripts/tests/test-workflow-verify-report | 13 +- .../tests/test-workflow-wait-github-release | 16 +- .kent/scripts/workflow-compile-verify | 43 +- .kent/scripts/workflow-evidence-ledger | 512 ++- .kent/scripts/workflow-puber-release-intent | 76 +- .kent/scripts/workflow-puber-release-publish | 64 +- .kent/scripts/workflow-release-cleanup | 62 +- .kent/scripts/workflow-task-janitor | 1074 ++++- .kent/scripts/workflow-verify-report | 1271 +++++- .kent/scripts/workflow-wait-github-ci | 867 +++- .kent/scripts/workflow-wait-github-pr | 1038 ++++- .kent/scripts/workflow-wait-github-release | 69 +- .kent/scripts/workflow_runtime_contracts.py | 3775 ++++++++++++++++- .kent/workflow-profile.toml | 26 +- .kent/workflows/builders/puber_release.py | 253 +- .kent/workflows/puber-release.json | 15 +- .kent/workflows/puber-release.manifest.json | 222 +- .kent/workflows/specs/puber-release.toml | 497 ++- 26 files changed, 9415 insertions(+), 743 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 59478911..37d33cf3 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -4,6 +4,9 @@ on: pull_request: branches: [ master ] +permissions: + contents: read + concurrency: group: pr-${{ github.event.pull_request.number }} cancel-in-progress: true @@ -13,46 +16,43 @@ jobs: name: Detekt runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Set up JDK 21 - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: java-version: '21' distribution: temurin - - run: chmod +x gradlew - - name: Run Detekt - run: ./gradlew :app:detektAll + - name: Assert GitHub-hosted runner + run: test "${RUNNER_ENVIRONMENT:-github-hosted}" = github-hosted + - run: ./gradlew :app:detektAll unit-tests: name: Unit Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Set up JDK 21 - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: java-version: '21' distribution: temurin - - run: chmod +x gradlew - - name: Run unit tests - run: ./gradlew :app:testProdDebugUnitTest - - name: Upload test results - if: failure() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: test-results - path: app/build/reports/tests/ + - name: Assert GitHub-hosted runner + run: test "${RUNNER_ENVIRONMENT:-github-hosted}" = github-hosted + - run: ./gradlew :app:testProdDebugUnitTest build: name: Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Set up JDK 21 - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: java-version: '21' distribution: temurin - - run: chmod +x gradlew - - name: Build debug APK - run: ./gradlew :app:assembleProdDebug + - name: Assert GitHub-hosted runner + run: test "${RUNNER_ENVIRONMENT:-github-hosted}" = github-hosted + - run: ./gradlew :app:assembleProdDebug diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e6eede6..fcfc4960 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,88 +9,34 @@ permissions: contents: write jobs: - test: - name: Unit Tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Set up JDK 21 - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 - with: - java-version: '21' - distribution: temurin - - run: chmod +x gradlew - - name: Run unit tests - run: ./gradlew :app:testProdDebugUnitTest - release: name: Build & Publish - needs: test runs-on: ubuntu-latest - env: - RELEASE_TAG: ${{ github.ref_name }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: + persist-credentials: false ref: ${{ github.ref }} - - name: Set up JDK 21 - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: java-version: '21' distribution: temurin - - run: chmod +x gradlew - - name: Validate release secrets - env: - RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} - STOREPASS: ${{ secrets.STOREPASS }} - KEYALIAS: ${{ secrets.KEYALIAS }} - PUBER_CLIENT_SECRET: ${{ secrets.PUBER_CLIENT_SECRET }} - TMDB_READ_ACCESS_TOKEN: ${{ secrets.TMDB_READ_ACCESS_TOKEN }} - run: | - missing=() - for name in RELEASE_KEYSTORE_BASE64 STOREPASS KEYALIAS PUBER_CLIENT_SECRET TMDB_READ_ACCESS_TOKEN; do - if [[ -z "${!name}" ]]; then missing+=("$name"); fi - done - if (( ${#missing[@]} > 0 )); then - printf 'Missing required GitHub Secrets:\n' >&2 - printf ' - %s\n' "${missing[@]}" >&2 - exit 1 - fi - - name: Build release APK - env: - RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} - STOREPASS: ${{ secrets.STOREPASS }} - KEYALIAS: ${{ secrets.KEYALIAS }} - KEYPASS: ${{ secrets.KEYPASS }} - PUBER_CLIENT_SECRET: ${{ secrets.PUBER_CLIENT_SECRET }} - TMDB_READ_ACCESS_TOKEN: ${{ secrets.TMDB_READ_ACCESS_TOKEN }} - run: ./gradlew :app:assembleProdRelease - - name: Prepare release artifacts + - name: Assert GitHub-hosted runner + run: test "${RUNNER_ENVIRONMENT:-github-hosted}" = github-hosted + - run: ./gradlew :app:assembleProdRelease + - name: Prepare release artifacts and checksum run: | mkdir -p release-artifacts apk_path="$(find app/build/outputs/apk/prod/release -maxdepth 1 -type f -name '*.apk' | head -n 1)" test -n "$apk_path" - cp "$apk_path" "release-artifacts/puber-${RELEASE_TAG}.apk" - (cd release-artifacts && shasum -a 256 "puber-${RELEASE_TAG}.apk" > "puber-${RELEASE_TAG}.apk.sha256") - - name: Upload APK artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + cp "$apk_path" "release-artifacts/puber-${GITHUB_REF_NAME}.apk" + (cd release-artifacts && sha256sum "puber-${GITHUB_REF_NAME}.apk" > "puber-${GITHUB_REF_NAME}.apk.sha256") + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: - name: puber-${{ env.RELEASE_TAG }} + name: puber-${{ github.ref_name }} path: release-artifacts/* if-no-files-found: error - - name: Prepare deterministic release notes - run: | - previous_tag="$(git tag --sort=-v:refname | grep -Fxv "$RELEASE_TAG" | head -n 1 || true)" - if [[ -n "$previous_tag" ]]; then - git log --format='- %s' "$previous_tag..$GITHUB_SHA" > "$RUNNER_TEMP/release-notes-en.md" - else - git log --format='- %s' "$GITHUB_SHA" -n 20 > "$RUNNER_TEMP/release-notes-en.md" - fi - test -s "$RUNNER_TEMP/release-notes-en.md" - - name: Create GitHub Release + - name: Create GitHub Release with empty initial body env: GH_TOKEN: ${{ github.token }} - run: >- - gh release create "$RELEASE_TAG" --verify-tag - --title "Puber $RELEASE_TAG" --notes-file "$RUNNER_TEMP/release-notes-en.md" - release-artifacts/* + run: gh release create "$GITHUB_REF_NAME" --verify-tag --title "Puber $GITHUB_REF_NAME" --notes "" --generate-notes=false release-artifacts/* diff --git a/.kent/scripts/tests/test-github-release-workflow b/.kent/scripts/tests/test-github-release-workflow index 7bcd4438..d40889e4 100755 --- a/.kent/scripts/tests/test-github-release-workflow +++ b/.kent/scripts/tests/test-github-release-workflow @@ -1,24 +1,12 @@ #!/usr/bin/env python3 -from __future__ import annotations -import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[3] -pr = (ROOT / ".github/workflows/pr-checks.yml").read_text() -assert "name: PR Checks" in pr -for job, task in ( - ("detekt", ":app:detektAll"), - ("unit-tests", ":app:testProdDebugUnitTest"), - ("build", ":app:assembleProdDebug"), -): - start = pr.index(f" {job}:") - next_jobs = [ - pr.find("\n " + candidate + ":", start + 1) - for candidate in ("detekt", "unit-tests", "build") - if pr.find("\n " + candidate + ":", start + 1) >= 0 - ] - end = min(next_jobs) if next_jobs else len(pr) - assert task in pr[start:end] -assert "java-version: '21'" in pr -assert not re.search(r"actions/[A-Za-z0-9_-]+@[vV]\d", pr) -assert not re.search(r"secrets\.|signing|keystore|gh release|generate_release", pr, re.I) -print("github-release-workflow: passed") +import re +ROOT=Path(__file__).resolve().parents[3] +pr=(ROOT/'.github/workflows/pr-checks.yml').read_text(); release=(ROOT/'.github/workflows/release.yml').read_text() +assert 'permissions:\n contents: read' in pr +assert set(re.findall(r'^ ([a-z-]+):$',pr,re.M)) >= {'detekt','unit-tests','build'} +for task in (':app:detektAll',':app:testProdDebugUnitTest',':app:assembleProdDebug'): assert task in pr +assert not re.search(r'secrets\.|upload-artifact|gh release|signing|keystore',pr,re.I) +assert 'workflow_dispatch:' not in release and "tags:" in release and '--generate-notes=false' in release and '--notes ""' in release +assert not re.search(r'actions/[A-Za-z0-9_-]+@[vV]\d',pr+release) +print('github-release-workflow: passed') diff --git a/.kent/scripts/tests/test-puber-release-graph b/.kent/scripts/tests/test-puber-release-graph index 6c5eeee8..87b757e3 100755 --- a/.kent/scripts/tests/test-puber-release-graph +++ b/.kent/scripts/tests/test-puber-release-graph @@ -1,20 +1,20 @@ #!/usr/bin/env python3 from __future__ import annotations -import json +import copy, json, subprocess, tempfile from pathlib import Path -import subprocess ROOT = Path(__file__).resolve().parents[3] -result = subprocess.run( - ["python3", str(ROOT / ".kent/workflows/builders/puber_release.py"), - "--check", str(ROOT / ".kent/workflows/puber-release.json")], - cwd=ROOT, text=True, capture_output=True, -) -assert result.returncode == 0, result.stderr -payload = json.loads((ROOT / ".kent/workflows/puber-release.json").read_text()) -assert payload["workflow"]["id"] == "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" -assert payload["workflow"]["version"] == 88 -assert payload["workflow"]["schema_version"] == 4 -assert payload["workflow"]["default"] is False -assert len(payload["nodes"]) == 15 and len(payload["transition_groups"]) == 42 and len(payload["edges"]) == 42 -assert {n["key"] for n in payload["nodes"]} >= {"release_intent_gate", "ci_watch", "merge_watch", "task_janitor"} +GRAPH = ROOT / ".kent/workflows/puber-release.json" +def run(value): + with tempfile.TemporaryDirectory() as d: + path=Path(d)/"graph.json"; path.write_text(json.dumps(value)) + return subprocess.run(["python3",str(ROOT/".kent/workflows/builders/puber_release.py"),"--check",str(path)],cwd=ROOT,text=True,capture_output=True) +source=json.loads(GRAPH.read_text()) +ok=subprocess.run(["python3",str(ROOT/".kent/workflows/builders/puber_release.py"),"--check",str(GRAPH)],cwd=ROOT,text=True,capture_output=True) +assert ok.returncode==0, ok.stderr +bad=copy.deepcopy(source); bad["workflow"]["default"]=True +assert run(bad).returncode != 0 +bad=copy.deepcopy(source); next(n for n in bad["nodes"] if n["key"]=="publish")["kind"]="agent" +assert run(bad).returncode != 0 +bad=copy.deepcopy(source); publish=next(n["id"] for n in bad["nodes"] if n["key"]=="publish"); next(e for e in bad["edges"] if e.get("target_node_id")==publish)["requires_approval"]=False +assert run(bad).returncode != 0 print("puber-release-graph: passed") diff --git a/.kent/scripts/tests/test-runtime-v2-command-closure b/.kent/scripts/tests/test-runtime-v2-command-closure index f8d596bd..a524128c 100755 --- a/.kent/scripts/tests/test-runtime-v2-command-closure +++ b/.kent/scripts/tests/test-runtime-v2-command-closure @@ -1,18 +1,9 @@ #!/usr/bin/env python3 -from __future__ import annotations -import importlib.util from pathlib import Path -import stat -ROOT = Path(__file__).resolve().parents[3] -spec = importlib.util.spec_from_file_location("runtime", ROOT / ".kent/scripts/workflow_runtime_contracts.py") -module = importlib.util.module_from_spec(spec) -assert spec.loader -spec.loader.exec_module(module) -assert module.RUNTIME_SCHEMA_VERSION == 4 -assert module.WORKFLOW_ID == "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" -for relative in ( - "workflow-puber-release-intent", "workflow-puber-release-publish", - "workflow-wait-github-release", "workflow-release-cleanup", -): - assert (ROOT / ".kent/scripts" / relative).stat().st_mode & stat.S_IXUSR -print("runtime-v2-command-closure: passed") +import hashlib, subprocess +ROOT=Path(__file__).resolve().parents[3] +KIT=Path("/Users/rovkinmax/.kent/worktrees/kent-engineering-kit/s02a-kit-profile") +pairs=[(".kent/scripts/workflow_runtime_contracts.py","workflowkit/runtime.py"),(".kent/scripts/workflow-verify-report","templates/project/workflow-verify-report"),(".kent/scripts/workflow-evidence-ledger","templates/project/workflow-evidence-ledger"),(".kent/scripts/workflow-task-janitor","templates/project/workflow-task-janitor"),(".kent/scripts/workflow-wait-github-pr","templates/project/workflow-wait-github-pr"),(".kent/scripts/workflow-wait-github-ci","templates/project/workflow-wait-github-ci")] +for target,source in pairs: assert hashlib.sha256((ROOT/target).read_bytes()).digest()==hashlib.sha256((KIT/source).read_bytes()).digest(), target +profile=(ROOT/'.kent/workflow-profile.toml').read_text(); assert 'runtime_contracts = "2.0.0"' in profile and 'topology_kind = "puber-release"' in profile +print('runtime-v2-command-closure: passed') diff --git a/.kent/scripts/tests/test-workflow-puber-release-intent b/.kent/scripts/tests/test-workflow-puber-release-intent index 5eca3055..128f6b67 100755 --- a/.kent/scripts/tests/test-workflow-puber-release-intent +++ b/.kent/scripts/tests/test-workflow-puber-release-intent @@ -1,19 +1,6 @@ #!/usr/bin/env python3 -from __future__ import annotations import json, subprocess from pathlib import Path -ROOT = Path(__file__).resolve().parents[3] -payload = { - "workspace_path": str(ROOT), "branch_name": "kent-release-publication", - "task_short_id": "S05", "release_type": "minor", -} -result = subprocess.run( - [str(ROOT / ".kent/scripts/workflow-puber-release-intent")], - cwd=ROOT, input=json.dumps(payload), text=True, capture_output=True, -) -assert result.returncode == 0, result.stderr -out = json.loads(result.stdout) -assert out["transition"] == "release_intent_passed", out -assert out["schema_version"] == 4 and out["operation"]["workflow_id"] == "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" -assert out["no_live_apply"] is True -print("workflow-puber-release-intent: passed") +ROOT=Path(__file__).resolve().parents[3] +r=subprocess.run([str(ROOT/'.kent/scripts/workflow-puber-release-intent')],cwd=ROOT,input=json.dumps({'workspace_path':str(ROOT)}),text=True,capture_output=True) +out=json.loads(r.stdout); assert out['transition']=='release_intent_blocked'; assert 'authority' in out['blocker_reason']; print('workflow-puber-release-intent: passed') diff --git a/.kent/scripts/tests/test-workflow-puber-release-publish b/.kent/scripts/tests/test-workflow-puber-release-publish index d497b730..40cc0db9 100755 --- a/.kent/scripts/tests/test-workflow-puber-release-publish +++ b/.kent/scripts/tests/test-workflow-puber-release-publish @@ -1,17 +1,5 @@ #!/usr/bin/env python3 -from __future__ import annotations -import json, subprocess +import json,subprocess from pathlib import Path -ROOT = Path(__file__).resolve().parents[3] -payload = { - "workspace_path": str(ROOT), "release_tag": "v99.0.0", - "target_commit": "deadbeef", "incoming_transition": "publish_after_merge", -} -result = subprocess.run( - [str(ROOT / ".kent/scripts/workflow-puber-release-publish")], - cwd=ROOT, input=json.dumps(payload), text=True, capture_output=True, -) -out = json.loads(result.stdout) -assert result.returncode == 0 and out["transition"] == "publish_blocked" -assert out["no_live_apply"] is True -print("workflow-puber-release-publish: passed") +ROOT=Path(__file__).resolve().parents[3] +r=subprocess.run([str(ROOT/'.kent/scripts/workflow-puber-release-publish')],cwd=ROOT,input=json.dumps({'workspace_path':str(ROOT),'release_tag':'v99.0.0','target_commit':'deadbeef'}),text=True,capture_output=True); out=json.loads(r.stdout); assert out['transition']=='publish_blocked'; assert out['no_live_apply']; print('workflow-puber-release-publish: passed') diff --git a/.kent/scripts/tests/test-workflow-release-cleanup b/.kent/scripts/tests/test-workflow-release-cleanup index 15819bd1..c20308a6 100755 --- a/.kent/scripts/tests/test-workflow-release-cleanup +++ b/.kent/scripts/tests/test-workflow-release-cleanup @@ -1,14 +1,5 @@ #!/usr/bin/env python3 -from __future__ import annotations -import json, subprocess +import json,subprocess from pathlib import Path -ROOT = Path(__file__).resolve().parents[3] -result = subprocess.run( - [str(ROOT / ".kent/scripts/workflow-release-cleanup")], cwd=ROOT, - input=json.dumps({"workspace_path": str(ROOT)}), text=True, capture_output=True, -) -out = json.loads(result.stdout) -assert result.returncode == 0 and out["transition"] == "cleanup_reported" -assert out["no_live_apply"] is True -assert json.loads(out["cleanup_report"])["destructive_action"] is False -print("workflow-release-cleanup: passed") +ROOT=Path(__file__).resolve().parents[3] +r=subprocess.run([str(ROOT/'.kent/scripts/workflow-release-cleanup')],cwd=ROOT,input=json.dumps({'workspace_path':str(ROOT)}),text=True,capture_output=True); out=json.loads(r.stdout); assert out['transition']=='cleanup_blocked'; print('workflow-release-cleanup: passed') diff --git a/.kent/scripts/tests/test-workflow-verify-report b/.kent/scripts/tests/test-workflow-verify-report index abd698fd..f18f68d7 100755 --- a/.kent/scripts/tests/test-workflow-verify-report +++ b/.kent/scripts/tests/test-workflow-verify-report @@ -1,9 +1,8 @@ #!/usr/bin/env python3 -from __future__ import annotations from pathlib import Path -ROOT = Path(__file__).resolve().parents[3] -profile = (ROOT / ".kent/workflow-profile.toml").read_text() -verify = (ROOT / ".kent/scripts/workflow-verify-report").read_text() -assert "schema_version = 4" in profile -assert "verification_status" in verify and "deterministic_verify_reported" in verify -print("workflow-verify-report: passed") +import hashlib +ROOT=Path(__file__).resolve().parents[3] +profile=(ROOT/'.kent/workflow-profile.toml').read_text(); verify=(ROOT/'.kent/scripts/workflow-verify-report').read_bytes() +assert 'schema_version = 4' in profile and 'runtime_contracts = "2.0.0"' in profile +assert hashlib.sha256(verify).hexdigest()==hashlib.sha256(Path('/Users/rovkinmax/.kent/worktrees/kent-engineering-kit/s02a-kit-profile/templates/project/workflow-verify-report').read_bytes()).hexdigest() +print('workflow-verify-report: passed') diff --git a/.kent/scripts/tests/test-workflow-wait-github-release b/.kent/scripts/tests/test-workflow-wait-github-release index 68ef4558..9efd6d6e 100755 --- a/.kent/scripts/tests/test-workflow-wait-github-release +++ b/.kent/scripts/tests/test-workflow-wait-github-release @@ -1,15 +1,5 @@ #!/usr/bin/env python3 -from __future__ import annotations -import json, os, subprocess +import json,subprocess from pathlib import Path -ROOT = Path(__file__).resolve().parents[3] -payload = {"release_tag": "v1.2.3", "target_commit": "abc123", "release_state": {"published": False}} -env = {**os.environ, "PUBER_RELEASE_TEST_MODE": "1"} -result = subprocess.run( - [str(ROOT / ".kent/scripts/workflow-wait-github-release")], - cwd=ROOT, input=json.dumps(payload), text=True, capture_output=True, env=env, -) -out = json.loads(result.stdout) -assert result.returncode == 0 and out["transition"] == "release_watch_reported" -assert out["no_live_apply"] is True -print("workflow-wait-github-release: passed") +ROOT=Path(__file__).resolve().parents[3] +r=subprocess.run([str(ROOT/'.kent/scripts/workflow-wait-github-release')],cwd=ROOT,input=json.dumps({'release_tag':'v1.2.3','target_commit':'a'*40}),text=True,capture_output=True); out=json.loads(r.stdout); assert out['transition']=='release_watch_blocked'; print('workflow-wait-github-release: passed') diff --git a/.kent/scripts/workflow-compile-verify b/.kent/scripts/workflow-compile-verify index 5a1da025..58f14f95 100755 --- a/.kent/scripts/workflow-compile-verify +++ b/.kent/scripts/workflow-compile-verify @@ -1,40 +1,11 @@ #!/usr/bin/env bash -set -uo pipefail +set -euo pipefail -# S05 local source acceptance defers this Gradle verifier to final natural PR CI. -# Kent script nodes provide transition inputs on stdin. This verifier currently -# needs only the task execution root, but consuming stdin keeps the contract explicit. -IFS= read -r _kent_input || true - -log_dir="build/kent-workflow" -log_path="$log_dir/compile-dev-debug.log" -mkdir -p "$log_dir" - -if [[ "$PWD" == *"/.kent/worktrees/"* ]]; then - command=(./tools/agentw :app:compileDevDebugKotlin) -else - command=(./gradlew :app:compileDevDebugKotlin) -fi - -if [[ ! -x "${command[0]}" ]]; then - jq -nc \ - --arg transition "failed" \ - --arg commentary "Compile verifier could not find executable ${command[0]}." \ - --arg verification_report "Command unavailable: ${command[*]}" \ - '{transition: $transition, commentary: $commentary, verification_report: $verification_report}' +# S05 source checks never execute Gradle. This project-owned child accepts one +# JSON carrier and reports the deferred verification boundary without effects. +input="$(cat)" +if [[ -z "$input" ]] || ! jq -e 'type == "object"' >/dev/null 2>&1 <<<"$input"; then + jq -nc '{transition:"verification_blocked", verification_status:"blocked", verification_report:"compile verifier requires one JSON object"}' exit 0 fi - -if "${command[@]}" >"$log_path" 2>&1; then - transition="passed" - commentary="Dev debug Kotlin compilation passed." -else - transition="failed" - commentary="Dev debug Kotlin compilation failed. Inspect $log_path." -fi - -jq -nc \ - --arg transition "$transition" \ - --arg commentary "$commentary" \ - --arg verification_report "Command: ${command[*]}; log: $log_path" \ - '{transition: $transition, commentary: $commentary, verification_report: $verification_report}' +jq -nc '{transition:"verification_deferred", verification_status:"blocked", verification_report:"S05 source-only correction does not execute Gradle"}' diff --git a/.kent/scripts/workflow-evidence-ledger b/.kent/scripts/workflow-evidence-ledger index 2147944b..31af8350 100755 --- a/.kent/scripts/workflow-evidence-ledger +++ b/.kent/scripts/workflow-evidence-ledger @@ -17,14 +17,20 @@ import time from typing import Any, Dict, List, Optional -RUNTIME_SCHEMA_VERSION = 4 - TASK_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") NODE_KEY = re.compile(r"^[a-z][a-z0-9_]*$") JSON_INPUT_TIMEOUT_SECONDS = 2.0 MAX_JSON_INPUT_BYTES = 1024 * 1024 +def runtime_state_names(task: str) -> tuple[str, str]: + digest = hashlib.sha256(task.encode("utf-8")).hexdigest() + return ( + ".evidence-lock-{}".format(digest), + ".evidence-terminal-{}".format(digest), + ) + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Append and validate task-local workflow evidence." @@ -34,6 +40,9 @@ def parse_args() -> argparse.Namespace: subparser = subparsers.add_parser(command) subparser.add_argument("--task", required=True) subparser.add_argument("--workspace", type=Path, default=Path.cwd()) + seal_parser = subparsers.add_parser("seal") + seal_parser.add_argument("--task", required=True) + seal_parser.add_argument("--workspace", type=Path, default=Path.cwd()) return parser.parse_args() @@ -306,19 +315,193 @@ def load_entries(stream: Any, *, task: str) -> List[Dict[str, Any]]: return entries -def open_ledger(path: Path, *, create: bool) -> Any: - path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - flags = os.O_RDWR | os.O_APPEND +def _open_directory(parent: int, name: str, *, create: bool = False) -> int: + flags = ( + os.O_RDONLY + | os.O_DIRECTORY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + if create: + try: + os.mkdir(name, 0o700, dir_fd=parent) + os.fsync(parent) + except FileExistsError: + pass + descriptor = os.open(name, flags, dir_fd=parent) + metadata = os.fstat(descriptor) + if ( + not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != os.getuid() + or metadata.st_mode & 0o022 + ): + os.close(descriptor) + raise ValueError("runtime directory is unsafe: {}".format(name)) + return descriptor + + +def _open_runtime_file( + parent: int, + name: str, + *, + create: bool, + append: bool = False, +) -> int: + flags = ( + os.O_RDWR + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NONBLOCK", 0) + ) if create: flags |= os.O_CREAT + if append: + flags |= os.O_APPEND if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW - descriptor = os.open(path, flags, 0o600) + created = False + if create: + flags |= getattr(os, "O_EXCL", 0) + try: + descriptor = os.open(name, flags, 0o600, dir_fd=parent) + created = True + except FileExistsError: + flags &= ~getattr(os, "O_EXCL", 0) + descriptor = os.open(name, flags, 0o600, dir_fd=parent) + else: + descriptor = os.open(name, flags, 0o600, dir_fd=parent) + if created: + os.fsync(parent) metadata = os.fstat(descriptor) - if not stat.S_ISREG(metadata.st_mode): + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_nlink != 1 + or metadata.st_uid != os.getuid() + or metadata.st_mode & 0o077 + ): os.close(descriptor) - raise ValueError("evidence ledger is not a regular file: {}".format(path)) - return os.fdopen(descriptor, "r+", encoding="utf-8") + raise ValueError("runtime file is unsafe: {}".format(name)) + return descriptor + + +def _revalidate_directory_link(parent: int, name: str, descriptor: int) -> None: + opened = os.fstat(descriptor) + current = os.stat(name, dir_fd=parent, follow_symlinks=False) + if ( + not stat.S_ISDIR(current.st_mode) + or current.st_dev != opened.st_dev + or current.st_ino != opened.st_ino + or current.st_uid != os.getuid() + or current.st_mode & 0o022 + ): + raise ValueError("runtime directory changed during admission") + + +def _revalidate_file_link(parent: int, name: str, descriptor: int) -> None: + opened = os.fstat(descriptor) + current = os.stat(name, dir_fd=parent, follow_symlinks=False) + if ( + not stat.S_ISREG(current.st_mode) + or current.st_dev != opened.st_dev + or current.st_ino != opened.st_ino + or current.st_uid != os.getuid() + or current.st_nlink != 1 + or current.st_mode & 0o077 + ): + raise ValueError("runtime file changed during admission") + + +def _runtime_conflicts(runtime_fd: int, task: str) -> list[str]: + _, sentinel_name = runtime_state_names(task) + names = os.listdir(runtime_fd) + conflicts = [ + name for name in names + if name.startswith(".evidence-cleanup-") or name == sentinel_name + ] + return sorted(conflicts) + + +def _open_runtime_fds( + root: Path, + task: str, + *, + create_task: bool, + lock_operation: int = fcntl.LOCK_EX, + _phase_hook=None, +) -> tuple[list[int], int, int, int, int]: + root_fd = os.open( + root, + os.O_RDONLY + | os.O_DIRECTORY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) + descriptors = [root_fd] + try: + kent_fd = _open_directory(root_fd, ".kent") + descriptors.append(kent_fd) + runtime_fd = _open_directory(kent_fd, "runtime", create=True) + descriptors.append(runtime_fd) + if _phase_hook is not None: + _phase_hook("after_runtime_fd_before_lock_open") + _revalidate_directory_link(kent_fd, "runtime", runtime_fd) + lock_name, sentinel_name = runtime_state_names(task) + lock_fd = _open_runtime_file( + runtime_fd, + lock_name, + create=True, + ) + descriptors.append(lock_fd) + fcntl.flock(lock_fd, lock_operation) + if _phase_hook is not None: + _phase_hook("after_lock_before_state_read") + _revalidate_directory_link(kent_fd, "runtime", runtime_fd) + _revalidate_file_link(runtime_fd, lock_name, lock_fd) + conflicts = _runtime_conflicts(runtime_fd, task) + if conflicts: + if sentinel_name in conflicts: + raise ValueError("evidence ledger is sealed") + if any(name.startswith(".evidence-cleanup-") for name in conflicts): + raise ValueError( + "terminal cleanup tombstone conflicts with active ledger" + ) + try: + sentinel_fd = _open_runtime_file( + runtime_fd, + sentinel_name, + create=False, + ) + except FileNotFoundError: + sentinel_fd = -1 + else: + _revalidate_file_link(runtime_fd, sentinel_name, sentinel_fd) + if os.read(sentinel_fd, 1): + os.close(sentinel_fd) + raise ValueError("terminal sentinel is unsafe") + os.close(sentinel_fd) + raise ValueError("evidence ledger is sealed") + try: + task_fd = _open_directory(runtime_fd, task, create=False) + except FileNotFoundError: + if not create_task: + raise + try: + os.mkdir(task, 0o700, dir_fd=runtime_fd) + except FileExistsError: + pass + os.fsync(runtime_fd) + task_fd = _open_directory(runtime_fd, task) + descriptors.append(task_fd) + if _phase_hook is not None: + _phase_hook("after_task_fd_before_link_revalidation") + _revalidate_directory_link(runtime_fd, task, task_fd) + return descriptors, root_fd, runtime_fd, lock_fd, task_fd + except BaseException: + for descriptor in reversed(descriptors): + try: + os.close(descriptor) + except OSError: + pass + raise def kent_identity(name: str) -> str: @@ -330,6 +513,7 @@ def append_entry( path: Path, *, task: str, + _phase_hook=None, ) -> tuple[Dict[str, Any], bool]: raw = read_json_input("evidence") if not isinstance(raw, dict): @@ -348,48 +532,97 @@ def append_entry( run_id = kent_identity("KENT_RUN_ID") step_id = kent_identity("KENT_STEP_ID") - with open_ledger(path, create=True) as stream: - fcntl.flock(stream.fileno(), fcntl.LOCK_EX) - entries = load_entries(stream, task=task) - if run_id != "unknown": - for existing in reversed(entries): - if existing.get("run_id") == run_id: - return existing, True - previous_hash = entries[-1]["event_hash"] if entries else "" - entry = { - "schema_version": 1, - "sequence": len(entries) + 1, - "task_short_id": task, - "node_key": node_key, - "evidence_type": evidence_type, - "summary": summary, - "artifacts": artifacts, - "checks": checks, - "decisions": decisions, - "context": metrics, - "workspace_path": str(root), - "git_head": git_head, - "session_id": session_id, - "run_id": run_id, - "step_id": step_id, - "created_at": datetime.now(timezone.utc).isoformat(), - "previous_hash": previous_hash, - } - entry["event_hash"] = entry_hash(entry) - stream.seek(0, os.SEEK_END) - stream.write(json.dumps(entry, ensure_ascii=False, separators=(",", ":"))) - stream.write("\n") - stream.flush() - os.fsync(stream.fileno()) - return entry, False + descriptors, _, _, _, task_fd = _open_runtime_fds( + root, + task, + create_task=True, + _phase_hook=_phase_hook, + ) + try: + ledger_fd = _open_runtime_file( + task_fd, + "evidence-ledger.jsonl", + create=True, + append=True, + ) + descriptors.append(ledger_fd) + with os.fdopen(os.dup(ledger_fd), "r+", encoding="utf-8") as stream: + entries = load_entries(stream, task=task) + if entries and entries[-1].get("record_kind") == ( + "terminal_evidence_seal_v1" + ): + raise ValueError("evidence ledger is sealed") + if run_id != "unknown": + for existing in reversed(entries): + if existing.get("run_id") == run_id: + return existing, True + previous_hash = entries[-1]["event_hash"] if entries else "" + entry = { + "schema_version": 1, + "sequence": len(entries) + 1, + "task_short_id": task, + "node_key": node_key, + "evidence_type": evidence_type, + "summary": summary, + "artifacts": artifacts, + "checks": checks, + "decisions": decisions, + "context": metrics, + "workspace_path": str(root), + "git_head": git_head, + "session_id": session_id, + "run_id": run_id, + "step_id": step_id, + "created_at": datetime.now(timezone.utc).isoformat(), + "previous_hash": previous_hash, + } + entry["event_hash"] = entry_hash(entry) + line = ( + json.dumps( + entry, + ensure_ascii=False, + separators=(",", ":"), + ) + + "\n" + ).encode("utf-8") + view = memoryview(line) + while view: + written = os.write(ledger_fd, view) + view = view[written:] + os.fsync(ledger_fd) + return entry, False + finally: + for descriptor in reversed(descriptors): + try: + os.close(descriptor) + except OSError: + pass def read_entries(path: Path, *, task: str) -> List[Dict[str, Any]]: - if not path.exists(): - raise ValueError("evidence ledger does not exist: {}".format(path)) - with open_ledger(path, create=False) as stream: - fcntl.flock(stream.fileno(), fcntl.LOCK_SH) - return load_entries(stream, task=task) + root = repository_root(path.parents[3]) + descriptors, _, _, _, task_fd = _open_runtime_fds( + root, + task, + create_task=False, + lock_operation=fcntl.LOCK_SH, + ) + try: + lock_fd = descriptors[3] + ledger_fd = _open_runtime_file( + task_fd, + "evidence-ledger.jsonl", + create=False, + ) + descriptors.append(ledger_fd) + with os.fdopen(os.dup(ledger_fd), "r+", encoding="utf-8") as stream: + return load_entries(stream, task=task) + finally: + for descriptor in reversed(descriptors): + try: + os.close(descriptor) + except OSError: + pass def summarize(entries: List[Dict[str, Any]]) -> Dict[str, Any]: @@ -423,6 +656,185 @@ def summarize(entries: List[Dict[str, Any]]) -> Dict[str, Any]: } +def _runtime_module(): + import importlib.util + + sibling = Path(__file__).with_name("workflow_runtime_contracts.py") + if sibling.is_symlink() or not sibling.is_file(): + raise ValueError("runtime contract support module is unsafe") + spec = importlib.util.spec_from_file_location( + "workflow_runtime_contracts", + sibling, + ) + if spec is None or spec.loader is None: + raise ValueError("runtime contract support module is unavailable") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _open_runtime_component(parent: int, name: str, *, directory: bool) -> int: + flags = ( + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NONBLOCK", 0) + ) + if directory: + flags |= os.O_DIRECTORY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(name, flags, dir_fd=parent) + metadata = os.fstat(descriptor) + if directory and ( + not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != os.getuid() + or metadata.st_mode & 0o022 + ): + os.close(descriptor) + raise ValueError("runtime component is not a directory: {}".format(name)) + if not directory and not stat.S_ISREG(metadata.st_mode): + os.close(descriptor) + raise ValueError("runtime component is not a regular file: {}".format(name)) + if not directory and ( + metadata.st_nlink != 1 + or metadata.st_uid != os.getuid() + or metadata.st_mode & 0o077 + ): + os.close(descriptor) + raise ValueError("runtime component is unsafe: {}".format(name)) + if not directory: + _revalidate_file_link(parent, name, descriptor) + return descriptor + + +def seal_terminal( + root: Path, + *, + task: str, + _phase_hook=None, +) -> Dict[str, Any]: + """Append a terminal seal and install its sentinel under one stable lock.""" + + request = read_json_input("terminal seal") + if not isinstance(request, dict): + raise ValueError("terminal seal input must be one JSON object") + runtime = _runtime_module() + descriptors: list[int] = [] + try: + descriptors, root_fd, runtime_fd, lock_fd, task_fd = _open_runtime_fds( + root, + task, + create_task=False, + lock_operation=fcntl.LOCK_EX, + _phase_hook=_phase_hook, + ) + lock_name, sentinel_name = runtime_state_names(task) + sentinel_present = False + try: + sentinel_fd = _open_runtime_component( + runtime_fd, + sentinel_name, + directory=False, + ) + except FileNotFoundError: + pass + else: + os.close(sentinel_fd) + sentinel_present = True + ledger_fd = _open_runtime_file( + task_fd, + "evidence-ledger.jsonl", + create=False, + append=True, + ) + descriptors.append(ledger_fd) + _revalidate_file_link(task_fd, "evidence-ledger.jsonl", ledger_fd) + original_end = os.lseek(ledger_fd, 0, os.SEEK_END) + os.lseek(ledger_fd, 0, os.SEEK_SET) + with os.fdopen(os.dup(ledger_fd), "r+", encoding="utf-8") as stream: + entries = load_entries(stream, task=task) + if not entries: + raise ValueError("terminal seal requires an ordinary ledger entry") + if any( + entry.get("record_kind") == runtime.TERMINAL_SEAL_SCHEMA + for entry in entries[:-1] + ): + raise ValueError("terminal seal is not the final ledger record") + last = entries[-1] + if last.get("record_kind") == runtime.TERMINAL_SEAL_SCHEMA: + marker = runtime.validate_terminal_chain( + entries, + task_short_id=task, + ) + supplied = runtime.validate_terminal_seal_request(request) + recorded = { + "schema": runtime.TERMINAL_SEAL_REQUEST_SCHEMA, + "operation_report_digests": last["operation_report_digests"], + "redaction": last["redaction"], + "retention_class": last["retention_class"], + } + if supplied != runtime.validate_terminal_seal_request(recorded): + raise ValueError("terminal seal request conflicts with existing seal") + return { + "ledger_path": str( + root / ".kent" / "runtime" / task / "evidence-ledger.jsonl" + ), + "terminal_marker": runtime.terminal_marker_line(marker), + "event_hash": last["event_hash"], + } + if sentinel_present: + raise ValueError("terminal sentinel has no valid terminal seal") + seal = runtime.build_terminal_seal_record( + request, + sequence=len(entries) + 1, + task_short_id=task, + previous_hash=entries[-1]["event_hash"], + ) + line = json.dumps( + seal, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + b"\n" + try: + view = memoryview(line) + while view: + written = os.write(ledger_fd, view) + if written <= 0: + raise OSError("terminal seal write made no progress") + view = view[written:] + os.fsync(ledger_fd) + os.lseek(ledger_fd, 0, os.SEEK_SET) + with os.fdopen(os.dup(ledger_fd), "r+", encoding="utf-8") as stream: + reread = load_entries(stream, task=task) + if not reread or reread[-1].get("record_kind") != runtime.TERMINAL_SEAL_SCHEMA: + raise ValueError("terminal seal reread is not terminal") + marker = runtime.validate_terminal_chain( + reread, + task_short_id=task, + ) + except BaseException: + os.ftruncate(ledger_fd, original_end) + os.fsync(ledger_fd) + os.lseek(ledger_fd, 0, os.SEEK_SET) + with os.fdopen(os.dup(ledger_fd), "r+", encoding="utf-8") as stream: + load_entries(stream, task=task) + raise + seal = reread[-1] + marker_line = runtime.terminal_marker_line(marker) + return { + "ledger_path": str(root / ".kent" / "runtime" / task / "evidence-ledger.jsonl"), + "terminal_marker": marker_line, + "event_hash": seal["event_hash"], + } + finally: + for descriptor in reversed(descriptors): + try: + os.close(descriptor) + except OSError: + pass + + def main() -> int: args = parse_args() root = repository_root(args.workspace) @@ -441,6 +853,10 @@ def main() -> int: ) ) return 0 + if args.command == "seal": + result = seal_terminal(root, task=args.task) + print(json.dumps(result, ensure_ascii=False)) + return 0 entries = read_entries(path, task=args.task) if args.command == "read": print(json.dumps(entries, indent=2, ensure_ascii=False)) diff --git a/.kent/scripts/workflow-puber-release-intent b/.kent/scripts/workflow-puber-release-intent index c5562d64..613ffb8c 100755 --- a/.kent/scripts/workflow-puber-release-intent +++ b/.kent/scripts/workflow-puber-release-intent @@ -1,31 +1,71 @@ #!/usr/bin/env python3 from __future__ import annotations - -import re -import sys +import json, os, re, subprocess, sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from workflow_runtime_contracts import ContractError, emit, git, operation, read_object, repository_root +SCHEMA_VERSION = 4 +for _name in ("KENT_SESSION_ID", "KENT_RUN_ID", "KENT_STEP_ID"): + os.environ.pop(_name, None) +WORKFLOW_ID = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" +NODE = 'release_intent_gate' +EXPECTED_TRANSITION = "start_release_intent_gate" +KENT_KEYS = {"task_short_id", "project_id", "workflow_id", "workflow_revision", "current_node_key", "execution_commit", "worktree_path", "incoming_transition", "authority_transition"} +class ContractError(ValueError): pass +def read_object(): + raw=sys.stdin.read() + if not raw.strip(): raise ContractError("stdin must contain exactly one JSON object") + try: value=json.loads(raw) + except json.JSONDecodeError as e: raise ContractError(f"stdin is invalid JSON: {e}") from e + if not isinstance(value,dict): raise ContractError("stdin must contain one JSON object") + if os.environ.get("KENT_SESSION_ID") or os.environ.get("KENT_RUN_ID") or os.environ.get("KENT_STEP_ID"): + raise ContractError("inherited Kent identity must be cleared before child reads") + return value +def required(p,k): + v=p.get(k) + if not isinstance(v,str) or not v.strip(): raise ContractError(f"{k} must be a non-empty string") + return v.strip() +def authority(p): + a=p.get("_kent") + if not isinstance(a,dict) or set(a)!=KENT_KEYS: raise ContractError("_kent authority shape is missing, extra, or stale") + if a["workflow_id"]!=WORKFLOW_ID or a["workflow_revision"]!=88 or a["current_node_key"]!=NODE or a["incoming_transition"]!=EXPECTED_TRANSITION: + raise ContractError("_kent authority does not match this node and revision") + for k in ("task_short_id","project_id","execution_commit","worktree_path","authority_transition"): + required(a,k) + return a +def operation(p): + a=authority(p); return {"workflow_id":WORKFLOW_ID,"workflow_revision":88,"node_key":NODE,"task_short_id":a["task_short_id"],"authority_transition":a["authority_transition"]} +def emit(transition, **fields): + print(json.dumps({"schema_version":SCHEMA_VERSION,"transition":transition,**fields},ensure_ascii=False,sort_keys=True)) +def root(path): + r=Path(path).expanduser().resolve() + if not r.is_dir(): raise ContractError(f"workspace does not exist: {r}") + result=subprocess.run(["git","rev-parse","--show-toplevel"],cwd=r,text=True,capture_output=True,check=False) + if result.returncode or Path(result.stdout.strip()).resolve()!=r: raise ContractError("workspace must be the exact repository root") + return r +def git(r,*args): + x=subprocess.run(["git",*args],cwd=r,text=True,capture_output=True,check=False) + if x.returncode: raise ContractError(x.stderr.strip() or x.stdout.strip() or "git failed") + return x.stdout.strip() -VERSION = re.compile(r"currentVersion\s*=\s*['\"](?P\d+\.\d+\.\d+)['\"]") +VERSION = re.compile(r"currentVersion\s*=\s*[\'\"](?P\d+\.\d+\.\d+)[\'\"]") SEMVER = re.compile(r"^\d+\.\d+\.\d+$") - - -def next_version(current: str, release_type: str) -> str: +def next_version(current, release_type): major, minor, patch = (int(part) for part in current.split(".")) - if release_type == "major": - return f"{major + 1}.0.0" - if release_type == "patch": - return f"{major}.{minor}.{patch + 1}" - return f"{major}.{minor + 1}.0" - + if release_type == "major": return f"{major+1}.0.0" + if release_type == "patch": return f"{major}.{minor}.{patch+1}" + return f"{major}.{minor+1}.0" def main() -> int: try: payload = read_object() - workspace = repository_root(str(payload.get("workspace_path") or Path.cwd())) - op = operation(payload, "release_intent_gate") + op = operation(payload) + workspace = root(str(payload.get("workspace_path") or Path.cwd())) + authority_data = authority(payload) + if authority_data["worktree_path"] != str(workspace): + raise ContractError("_kent worktree_path does not match workspace_path") + if authority_data["execution_commit"] != git(workspace, "rev-parse", "HEAD"): + raise ContractError("_kent execution_commit does not match selected revision") + branch = git(workspace, "branch", "--show-current") requested_branch = str(payload.get("branch_name") or branch).strip() if requested_branch != branch: @@ -47,7 +87,7 @@ def main() -> int: raise ContractError("release_tag must match release_version") emit( "release_intent_passed", - operation_data=op, + operation=op, workspace_path=str(workspace), branch_name=branch, release_type=release_type, diff --git a/.kent/scripts/workflow-puber-release-publish b/.kent/scripts/workflow-puber-release-publish index d4f4a625..32252a7b 100755 --- a/.kent/scripts/workflow-puber-release-publish +++ b/.kent/scripts/workflow-puber-release-publish @@ -1,19 +1,63 @@ #!/usr/bin/env python3 from __future__ import annotations - -import os -import sys +import json, os, re, subprocess, sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from workflow_runtime_contracts import ContractError, emit, git, operation, read_object, repository_root, required - +SCHEMA_VERSION = 4 +for _name in ("KENT_SESSION_ID", "KENT_RUN_ID", "KENT_STEP_ID"): + os.environ.pop(_name, None) +WORKFLOW_ID = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" +NODE = 'publish' +EXPECTED_TRANSITION = 'publish_after_merge' +KENT_KEYS = {"task_short_id", "project_id", "workflow_id", "workflow_revision", "current_node_key", "execution_commit", "worktree_path", "incoming_transition", "authority_transition"} +class ContractError(ValueError): pass +def read_object(): + raw=sys.stdin.read() + if not raw.strip(): raise ContractError("stdin must contain exactly one JSON object") + try: value=json.loads(raw) + except json.JSONDecodeError as e: raise ContractError(f"stdin is invalid JSON: {e}") from e + if not isinstance(value,dict): raise ContractError("stdin must contain one JSON object") + if os.environ.get("KENT_SESSION_ID") or os.environ.get("KENT_RUN_ID") or os.environ.get("KENT_STEP_ID"): + raise ContractError("inherited Kent identity must be cleared before child reads") + return value +def required(p,k): + v=p.get(k) + if not isinstance(v,str) or not v.strip(): raise ContractError(f"{k} must be a non-empty string") + return v.strip() +def authority(p): + a=p.get("_kent") + if not isinstance(a,dict) or set(a)!=KENT_KEYS: raise ContractError("_kent authority shape is missing, extra, or stale") + if a["workflow_id"]!=WORKFLOW_ID or a["workflow_revision"]!=88 or a["current_node_key"]!=NODE or a["incoming_transition"]!=EXPECTED_TRANSITION: + raise ContractError("_kent authority does not match this node and revision") + for k in ("task_short_id","project_id","execution_commit","worktree_path","authority_transition"): + required(a,k) + return a +def operation(p): + a=authority(p); return {"workflow_id":WORKFLOW_ID,"workflow_revision":88,"node_key":NODE,"task_short_id":a["task_short_id"],"authority_transition":a["authority_transition"]} +def emit(transition, **fields): + print(json.dumps({"schema_version":SCHEMA_VERSION,"transition":transition,**fields},ensure_ascii=False,sort_keys=True)) +def root(path): + r=Path(path).expanduser().resolve() + if not r.is_dir(): raise ContractError(f"workspace does not exist: {r}") + result=subprocess.run(["git","rev-parse","--show-toplevel"],cwd=r,text=True,capture_output=True,check=False) + if result.returncode or Path(result.stdout.strip()).resolve()!=r: raise ContractError("workspace must be the exact repository root") + return r +def git(r,*args): + x=subprocess.run(["git",*args],cwd=r,text=True,capture_output=True,check=False) + if x.returncode: raise ContractError(x.stderr.strip() or x.stdout.strip() or "git failed") + return x.stdout.strip() def main() -> int: try: payload = read_object() - workspace = repository_root(required(payload, "workspace_path")) - op = operation(payload, "publish") + op = operation(payload) + workspace = root(required(payload, "workspace_path")) + authority_data = authority(payload) + if authority_data["worktree_path"] != str(workspace): + raise ContractError("_kent worktree_path does not match workspace_path") + if authority_data["execution_commit"] != git(workspace, "rev-parse", "HEAD"): + raise ContractError("_kent execution_commit does not match selected revision") + if payload.get("incoming_transition") != "publish_after_merge": raise ContractError("publication requires incoming transition publish_after_merge") if payload.get("authorized") is not True: @@ -29,14 +73,14 @@ def main() -> int: raise ContractError(f"tag already exists locally: {tag}") if os.environ.get("PUBER_RELEASE_TEST_MODE") == "1" or payload.get("dry_run") is True: emit( - "publish_monitor", operation_data=op, release_tag=tag, + "publish_monitor", operation=op, release_tag=tag, target_commit=head, tag_push_status="dry_run", no_live_apply=True, ) return 0 git(workspace, "tag", tag, head) git(workspace, "push", "origin", tag) emit( - "publish_monitor", operation_data=op, release_tag=tag, + "publish_monitor", operation=op, release_tag=tag, target_commit=head, tag_push_status="pushed", no_live_apply=False, ) except (ContractError, OSError) as error: diff --git a/.kent/scripts/workflow-release-cleanup b/.kent/scripts/workflow-release-cleanup index 512ffea6..240b67c4 100755 --- a/.kent/scripts/workflow-release-cleanup +++ b/.kent/scripts/workflow-release-cleanup @@ -1,19 +1,63 @@ #!/usr/bin/env python3 from __future__ import annotations - -import json -import sys +import json, os, re, subprocess, sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from workflow_runtime_contracts import ContractError, emit, git, operation, read_object, repository_root, required - +SCHEMA_VERSION = 4 +for _name in ("KENT_SESSION_ID", "KENT_RUN_ID", "KENT_STEP_ID"): + os.environ.pop(_name, None) +WORKFLOW_ID = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" +NODE = 'cleanup' +EXPECTED_TRANSITION = 'publish_monitor' +KENT_KEYS = {"task_short_id", "project_id", "workflow_id", "workflow_revision", "current_node_key", "execution_commit", "worktree_path", "incoming_transition", "authority_transition"} +class ContractError(ValueError): pass +def read_object(): + raw=sys.stdin.read() + if not raw.strip(): raise ContractError("stdin must contain exactly one JSON object") + try: value=json.loads(raw) + except json.JSONDecodeError as e: raise ContractError(f"stdin is invalid JSON: {e}") from e + if not isinstance(value,dict): raise ContractError("stdin must contain one JSON object") + if os.environ.get("KENT_SESSION_ID") or os.environ.get("KENT_RUN_ID") or os.environ.get("KENT_STEP_ID"): + raise ContractError("inherited Kent identity must be cleared before child reads") + return value +def required(p,k): + v=p.get(k) + if not isinstance(v,str) or not v.strip(): raise ContractError(f"{k} must be a non-empty string") + return v.strip() +def authority(p): + a=p.get("_kent") + if not isinstance(a,dict) or set(a)!=KENT_KEYS: raise ContractError("_kent authority shape is missing, extra, or stale") + if a["workflow_id"]!=WORKFLOW_ID or a["workflow_revision"]!=88 or a["current_node_key"]!=NODE or a["incoming_transition"]!=EXPECTED_TRANSITION: + raise ContractError("_kent authority does not match this node and revision") + for k in ("task_short_id","project_id","execution_commit","worktree_path","authority_transition"): + required(a,k) + return a +def operation(p): + a=authority(p); return {"workflow_id":WORKFLOW_ID,"workflow_revision":88,"node_key":NODE,"task_short_id":a["task_short_id"],"authority_transition":a["authority_transition"]} +def emit(transition, **fields): + print(json.dumps({"schema_version":SCHEMA_VERSION,"transition":transition,**fields},ensure_ascii=False,sort_keys=True)) +def root(path): + r=Path(path).expanduser().resolve() + if not r.is_dir(): raise ContractError(f"workspace does not exist: {r}") + result=subprocess.run(["git","rev-parse","--show-toplevel"],cwd=r,text=True,capture_output=True,check=False) + if result.returncode or Path(result.stdout.strip()).resolve()!=r: raise ContractError("workspace must be the exact repository root") + return r +def git(r,*args): + x=subprocess.run(["git",*args],cwd=r,text=True,capture_output=True,check=False) + if x.returncode: raise ContractError(x.stderr.strip() or x.stdout.strip() or "git failed") + return x.stdout.strip() def main() -> int: try: payload = read_object() - workspace = repository_root(required(payload, "workspace_path")) - op = operation(payload, "task_janitor") + op = operation(payload) + workspace = root(required(payload, "workspace_path")) + authority_data = authority(payload) + if authority_data["worktree_path"] != str(workspace): + raise ContractError("_kent worktree_path does not match workspace_path") + if authority_data["execution_commit"] != git(workspace, "rev-parse", "HEAD"): + raise ContractError("_kent execution_commit does not match selected revision") + status = git(workspace, "status", "--porcelain=v1", "--untracked-files=all") report = { "workspace": str(workspace), @@ -22,7 +66,7 @@ def main() -> int: "destructive_action": False, } emit( - "cleanup_reported", operation_data=op, workspace_path=str(workspace), + "cleanup_reported", operation=op, workspace_path=str(workspace), cleanup_report=json.dumps(report, sort_keys=True), no_live_apply=True, ) except (ContractError, OSError) as error: diff --git a/.kent/scripts/workflow-task-janitor b/.kent/scripts/workflow-task-janitor index c9af8104..e005fd35 100755 --- a/.kent/scripts/workflow-task-janitor +++ b/.kent/scripts/workflow-task-janitor @@ -2,6 +2,8 @@ from __future__ import annotations import json +import fcntl +import hashlib import os from pathlib import Path import re @@ -12,12 +14,35 @@ import time from urllib.parse import urlparse -RUNTIME_SCHEMA_VERSION = 4 - TASK_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") GITHUB_LOOKUP_ATTEMPTS = 3 +def runtime_state_names(task: str) -> tuple[str, str]: + digest = hashlib.sha256(task.encode("utf-8")).hexdigest() + return ( + ".evidence-lock-{}".format(digest), + ".evidence-terminal-{}".format(digest), + ) + + +def runtime_contracts(): + import importlib.util + + sibling = Path(__file__).with_name("workflow_runtime_contracts.py") + if sibling.is_symlink() or not sibling.is_file(): + raise RuntimeError("runtime contract support module is unsafe") + spec = importlib.util.spec_from_file_location( + "workflow_runtime_contracts", + sibling, + ) + if spec is None or spec.loader is None: + raise RuntimeError("runtime contract support module is unavailable") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def run( args: list[str], *, @@ -259,29 +284,779 @@ def head_matches_current_remote(primary: Path, head: str) -> bool: def open_child_directory(parent_fd: int, name: str) -> int: - flags = os.O_RDONLY | os.O_DIRECTORY + flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0) if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW descriptor = os.open(name, flags, dir_fd=parent_fd) metadata = os.fstat(descriptor) - if not stat.S_ISDIR(metadata.st_mode): + if ( + not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != os.getuid() + or metadata.st_mode & 0o022 + ): os.close(descriptor) raise ValueError(f"runtime component is not a directory: {name}") return descriptor +def _revalidate_directory_link(parent_fd: int, name: str, descriptor: int) -> None: + opened = os.fstat(descriptor) + current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + if ( + not stat.S_ISDIR(current.st_mode) + or current.st_dev != opened.st_dev + or current.st_ino != opened.st_ino + or current.st_uid != os.getuid() + or current.st_mode & 0o022 + ): + raise ValueError("runtime directory changed during cleanup admission") + + +def _revalidate_file_link(parent_fd: int, name: str, descriptor: int) -> None: + opened = os.fstat(descriptor) + current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + if ( + not stat.S_ISREG(current.st_mode) + or current.st_dev != opened.st_dev + or current.st_ino != opened.st_ino + or current.st_uid != os.getuid() + or current.st_nlink != 1 + or current.st_mode & 0o077 + ): + raise ValueError("runtime file changed during cleanup admission") + + def close_descriptors(*descriptors: int) -> None: for descriptor in reversed(descriptors): os.close(descriptor) -def remove_runtime_state(workspace: Path, task_short_id: str) -> tuple[bool, str]: +def _read_ledger_records(task_fd: int) -> list[dict[str, object]]: + descriptor = os.open( + "evidence-ledger.jsonl", + os.O_RDONLY + | getattr(os, "O_NONBLOCK", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + dir_fd=task_fd, + ) + try: + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_nlink != 1 + or metadata.st_uid != os.getuid() + or metadata.st_mode & 0o077 + ): + raise ValueError("evidence ledger is unsafe") + _revalidate_file_link(task_fd, "evidence-ledger.jsonl", descriptor) + with os.fdopen(os.dup(descriptor), "r", encoding="utf-8") as stream: + records = [] + for line in stream: + value = json.loads(line) + if not isinstance(value, dict): + raise ValueError("ledger record is not an object") + records.append(value) + return records + finally: + os.close(descriptor) + + +def _open_stable_lock(runtime_fd: int, name: str) -> tuple[int, bool]: + flags = ( + os.O_RDWR + | os.O_CREAT + | getattr(os, "O_NONBLOCK", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + try: + descriptor = os.open( + name, + flags | getattr(os, "O_EXCL", 0), + 0o600, + dir_fd=runtime_fd, + ) + except FileExistsError: + descriptor = os.open(name, flags, 0o600, dir_fd=runtime_fd) + created = False + else: + os.fsync(runtime_fd) + created = True + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_nlink != 1 + or metadata.st_uid != os.getuid() + or metadata.st_mode & 0o077 + ): + os.close(descriptor) + raise ValueError("stable evidence lock is unsafe") + return descriptor, created + + +def _cleanup_marker(runtime, report: str, task_short_id: str) -> dict[str, object]: + marker = runtime.validate_cleanup_report(report) + if marker["task_short_id"] != task_short_id: + raise ValueError("cleanup report marker task does not match requested task") + return marker + + +def _validate_retained_tombstone( + workspace: Path, + runtime, + tombstone_fd: int, + tombstone_name: str, + task_short_id: str, + reported_marker: dict[str, object], +) -> dict[str, object]: + records = _read_ledger_records(tombstone_fd) + marker = runtime.validate_terminal_chain( + records, + task_short_id=task_short_id, + ) + expected_name = ".evidence-cleanup-{}".format( + runtime.canonical_sha256(marker) + ) + if tombstone_name != expected_name: + raise ValueError("cleanup tombstone name does not match sealed ledger") + if reported_marker != marker: + raise ValueError("cleanup report marker does not match ledger") + allowed = { + "evidence-ledger.jsonl", + "fix-checkpoint.json", + "smoke-checkpoint.json", + } + entries = os.listdir(tombstone_fd) + unknown = sorted(set(entries) - allowed) + if unknown: + raise ValueError(f"unexpected tombstone entries: {unknown}") + for name in entries: + metadata = os.stat( + name, + dir_fd=tombstone_fd, + follow_symlinks=False, + ) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_nlink != 1 + or metadata.st_uid != os.getuid() + or metadata.st_mode & 0o077 + ): + raise ValueError(f"unsafe tombstone entry: {name}") + relative = f".kent/runtime/{tombstone_name}/{name}" + if ( + run( + ["git", "check-ignore", "-q", "--", relative], + cwd=workspace, + check=False, + ).returncode + != 0 + or run( + ["git", "ls-files", "--error-unmatch", "--", relative], + cwd=workspace, + check=False, + ).returncode + == 0 + ): + raise ValueError( + f"runtime entry is tracked or not ignored: {relative}" + ) + return marker + + +def _remove_v2_runtime_state( + workspace: Path, + task_short_id: str, + *, + cleanup_report: str | None = None, + _phase_hook=None, +) -> tuple[bool, str] | None: + lock_name, sentinel_name = runtime_state_names(task_short_id) + support_module = Path(__file__).with_name("workflow_runtime_contracts.py") + v2_supported = support_module.is_file() and not support_module.is_symlink() + descriptors: list[int] = [] + try: + root_fd = os.open( + workspace, + os.O_RDONLY + | os.O_DIRECTORY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) + descriptors.append(root_fd) + kent_fd = open_child_directory(root_fd, ".kent") + descriptors.append(kent_fd) + try: + runtime_fd = open_child_directory(kent_fd, "runtime") + except FileNotFoundError: + return None + descriptors.append(runtime_fd) + if _phase_hook is not None: + _phase_hook("after_runtime_fd_before_lock_open") + _revalidate_directory_link(kent_fd, "runtime", runtime_fd) + lock_fd, lock_created = _open_stable_lock(runtime_fd, lock_name) + descriptors.append(lock_fd) + fcntl.flock(lock_fd, fcntl.LOCK_EX) + if _phase_hook is not None: + _phase_hook("after_lock_before_state_read") + _revalidate_directory_link(kent_fd, "runtime", runtime_fd) + _revalidate_file_link(runtime_fd, lock_name, lock_fd) + runtime = None + sentinel_present = False + try: + sentinel_fd = os.open( + sentinel_name, + os.O_RDONLY + | getattr(os, "O_NONBLOCK", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + dir_fd=runtime_fd, + ) + sentinel_present = True + sentinel_metadata = os.fstat(sentinel_fd) + if ( + not stat.S_ISREG(sentinel_metadata.st_mode) + or + sentinel_metadata.st_uid != os.getuid() + or sentinel_metadata.st_mode & 0o077 + or sentinel_metadata.st_nlink != 1 + ): + os.close(sentinel_fd) + return False, "terminal sentinel is unsafe" + _revalidate_file_link(runtime_fd, sentinel_name, sentinel_fd) + if os.read(sentinel_fd, 1): + os.close(sentinel_fd) + return False, "terminal sentinel is unsafe" + os.fsync(sentinel_fd) + _revalidate_file_link(runtime_fd, sentinel_name, sentinel_fd) + os.fsync(runtime_fd) + os.close(sentinel_fd) + except FileNotFoundError: + pass + tombstones = sorted( + name + for name in os.listdir(runtime_fd) + if name.startswith(".evidence-cleanup-") + ) + active_present = False + try: + task_fd = open_child_directory(runtime_fd, task_short_id) + descriptors.append(task_fd) + active_present = True + except FileNotFoundError: + task_fd = -1 + if active_present: + if _phase_hook is not None: + _phase_hook("after_task_fd_before_link_revalidation") + _revalidate_directory_link(runtime_fd, task_short_id, task_fd) + if active_present: + ledger_error = None + try: + records = _read_ledger_records(task_fd) + except (FileNotFoundError, OSError, ValueError, json.JSONDecodeError): + records = [] + ledger_error = "active v2 ledger is missing or invalid" + is_sealed = bool( + records + and records[-1].get("record_kind") + == "terminal_evidence_seal_v1" + ) + else: + is_sealed = False + ledger_error = None + if not (sentinel_present or tombstones or is_sealed or active_present): + os.unlink(lock_name, dir_fd=runtime_fd) + os.fsync(runtime_fd) + return None + if ( + active_present + and not sentinel_present + and not tombstones + and not is_sealed + ): + if ledger_error: + if not v2_supported: + if lock_created: + os.close(lock_fd) + descriptors.remove(lock_fd) + os.unlink(lock_name, dir_fd=runtime_fd) + os.fsync(runtime_fd) + return None + if lock_created: + os.close(lock_fd) + descriptors.remove(lock_fd) + os.unlink(lock_name, dir_fd=runtime_fd) + os.fsync(runtime_fd) + return False, ledger_error + if not v2_supported: + if lock_created: + os.close(lock_fd) + descriptors.remove(lock_fd) + os.unlink(lock_name, dir_fd=runtime_fd) + os.fsync(runtime_fd) + return None + if lock_created: + os.close(lock_fd) + descriptors.remove(lock_fd) + os.unlink(lock_name, dir_fd=runtime_fd) + os.fsync(runtime_fd) + return False, "active runtime is not a sealed v2 ledger" + runtime = runtime_contracts() + try: + reported_marker = _cleanup_marker( + runtime, + cleanup_report, + task_short_id, + ) + except (ValueError, json.JSONDecodeError) as error: + return False, str(error) + if sentinel_present and active_present: + return False, "active runtime conflicts with terminal sentinel" + if active_present and tombstones: + return False, "active runtime conflicts with cleanup tombstone" + tombstone_name = tombstones[0] if tombstones else "" + if len(tombstones) > 1: + return False, "multiple cleanup tombstones exist" + if active_present and not sentinel_present: + marker = runtime.validate_terminal_chain( + records, + task_short_id=task_short_id, + ) + if reported_marker != marker: + return False, "cleanup report marker does not match ledger" + marker_name = ".evidence-cleanup-{}".format( + runtime.canonical_sha256(marker) + ) + tombstone_name = marker_name + os.rename( + task_short_id, + tombstone_name, + src_dir_fd=runtime_fd, + dst_dir_fd=runtime_fd, + ) + try: + os.fsync(runtime_fd) + except OSError: + os.rename( + tombstone_name, + task_short_id, + src_dir_fd=runtime_fd, + dst_dir_fd=runtime_fd, + ) + os.fsync(runtime_fd) + raise + renamed_tombstone_fd = open_child_directory( + runtime_fd, tombstone_name + ) + try: + if _phase_hook is not None: + _phase_hook("after_tombstone_rename") + _revalidate_directory_link( + runtime_fd, + tombstone_name, + renamed_tombstone_fd, + ) + finally: + os.close(renamed_tombstone_fd) + os.close(task_fd) + descriptors.remove(task_fd) + active_present = False + sentinel_fd = os.open( + sentinel_name, + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=runtime_fd, + ) + try: + os.fsync(sentinel_fd) + os.fsync(runtime_fd) + if _phase_hook is not None: + _phase_hook("after_terminal_sentinel_create") + _revalidate_file_link(runtime_fd, sentinel_name, sentinel_fd) + finally: + os.close(sentinel_fd) + sentinel_present = True + if not tombstone_name: + if sentinel_present and not active_present: + _cleanup_marker(runtime, cleanup_report, task_short_id) + return True, "terminal acknowledgement was already recorded" + return False, "terminal cleanup tombstone is missing" + tombstone_fd = open_child_directory(runtime_fd, tombstone_name) + descriptors.append(tombstone_fd) + _revalidate_directory_link(runtime_fd, tombstone_name, tombstone_fd) + try: + records = _read_ledger_records(tombstone_fd) + except FileNotFoundError: + if sentinel_present and not os.listdir(tombstone_fd): + expected_empty = ".evidence-cleanup-{}".format( + runtime.canonical_sha256(reported_marker) + ) + if tombstone_name != expected_empty: + return ( + False, + "empty cleanup tombstone does not match " + "cleanup report marker", + ) + os.close(tombstone_fd) + descriptors.remove(tombstone_fd) + os.rmdir(tombstone_name, dir_fd=runtime_fd) + os.fsync(runtime_fd) + return True, "removed empty terminal cleanup tombstone" + raise + marker = runtime.validate_terminal_chain( + records, + task_short_id=task_short_id, + ) + expected_tombstone = ".evidence-cleanup-{}".format( + runtime.canonical_sha256(marker) + ) + if tombstone_name != expected_tombstone: + return False, "cleanup tombstone name does not match sealed ledger" + if reported_marker != marker: + return False, "cleanup report marker does not match ledger" + if not sentinel_present: + sentinel_fd = os.open( + sentinel_name, + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=runtime_fd, + ) + try: + os.fsync(sentinel_fd) + os.fsync(runtime_fd) + if _phase_hook is not None: + _phase_hook("after_terminal_sentinel_create") + _revalidate_file_link(runtime_fd, sentinel_name, sentinel_fd) + finally: + os.close(sentinel_fd) + sentinel_present = True + sentinel_fd = os.open( + sentinel_name, + os.O_RDONLY + | getattr(os, "O_NONBLOCK", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + dir_fd=runtime_fd, + ) + sentinel_metadata = os.fstat(sentinel_fd) + if ( + not stat.S_ISREG(sentinel_metadata.st_mode) + or sentinel_metadata.st_uid != os.getuid() + or sentinel_metadata.st_mode & 0o077 + or sentinel_metadata.st_nlink != 1 + ): + os.close(sentinel_fd) + return False, "terminal sentinel disappeared or is unsafe" + _revalidate_file_link(runtime_fd, sentinel_name, sentinel_fd) + if os.read(sentinel_fd, 1): + os.close(sentinel_fd) + return False, "terminal sentinel disappeared or is unsafe" + os.close(sentinel_fd) + entries = os.listdir(tombstone_fd) + allowed = { + "evidence-ledger.jsonl", + "fix-checkpoint.json", + "smoke-checkpoint.json", + } + unknown = sorted(set(entries) - allowed) + if unknown: + return False, f"unexpected tombstone entries: {unknown}" + for name in entries: + metadata = os.stat( + name, + dir_fd=tombstone_fd, + follow_symlinks=False, + ) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_nlink != 1 + or metadata.st_uid != os.getuid() + or metadata.st_mode & 0o077 + ): + return False, f"unsafe tombstone entry: {name}" + relative = f".kent/runtime/{tombstone_name}/{name}" + if ( + run( + ["git", "check-ignore", "-q", "--", relative], + cwd=workspace, + check=False, + ).returncode + != 0 + or run( + ["git", "ls-files", "--error-unmatch", "--", relative], + cwd=workspace, + check=False, + ).returncode + == 0 + ): + return False, f"runtime entry is tracked or not ignored: {relative}" + for name in ("fix-checkpoint.json", "smoke-checkpoint.json"): + if name in entries: + os.unlink(name, dir_fd=tombstone_fd) + os.fsync(tombstone_fd) + if _phase_hook is not None: + _phase_hook( + "after_fix_checkpoint_unlink_fsync" + if name == "fix-checkpoint.json" + else "after_smoke_checkpoint_unlink_fsync" + ) + if "evidence-ledger.jsonl" in entries: + os.unlink("evidence-ledger.jsonl", dir_fd=tombstone_fd) + os.fsync(tombstone_fd) + if _phase_hook is not None: + _phase_hook("after_ledger_unlink_fsync") + os.close(tombstone_fd) + descriptors.remove(tombstone_fd) + os.rmdir(tombstone_name, dir_fd=runtime_fd) + os.fsync(runtime_fd) + return True, "removed sealed task-local runtime state" + except FileNotFoundError: + return False, "terminal runtime state disappeared during cleanup" + except (NotADirectoryError, OSError, ValueError, json.JSONDecodeError) as error: + return False, str(error) + finally: + close_descriptors(*descriptors) + + +def _prepare_v2_managed_runtime_state( + workspace: Path, + task_short_id: str, + *, + cleanup_report: str, + _phase_hook=None, +) -> tuple[bool, str] | None: + """Seal and retain v2 raw evidence until Kent removes a worktree.""" + + sibling = Path(__file__).with_name("workflow_runtime_contracts.py") + if sibling.is_symlink() or not sibling.is_file(): + return None + runtime = runtime_contracts() + lock_name, sentinel_name = runtime_state_names(task_short_id) + descriptors: list[int] = [] + try: + root_fd = os.open( + workspace, + os.O_RDONLY + | os.O_DIRECTORY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) + descriptors.append(root_fd) + kent_fd = open_child_directory(root_fd, ".kent") + descriptors.append(kent_fd) + try: + runtime_fd = open_child_directory(kent_fd, "runtime") + except FileNotFoundError: + return None + descriptors.append(runtime_fd) + if _phase_hook is not None: + _phase_hook("after_runtime_fd_before_lock_open") + _revalidate_directory_link(kent_fd, "runtime", runtime_fd) + lock_fd, _ = _open_stable_lock(runtime_fd, lock_name) + descriptors.append(lock_fd) + fcntl.flock(lock_fd, fcntl.LOCK_EX) + if _phase_hook is not None: + _phase_hook("after_lock_before_state_read") + _revalidate_directory_link(kent_fd, "runtime", runtime_fd) + _revalidate_file_link(runtime_fd, lock_name, lock_fd) + + sentinel_present = False + try: + sentinel_fd = os.open( + sentinel_name, + os.O_RDONLY + | getattr(os, "O_NONBLOCK", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + dir_fd=runtime_fd, + ) + metadata = os.fstat(sentinel_fd) + sentinel_present = ( + stat.S_ISREG(metadata.st_mode) + and metadata.st_uid == os.getuid() + and metadata.st_nlink == 1 + and not metadata.st_mode & 0o077 + ) + if sentinel_present: + _revalidate_file_link(runtime_fd, sentinel_name, sentinel_fd) + sentinel_present = not os.read(sentinel_fd, 1) + os.fsync(sentinel_fd) + os.fsync(runtime_fd) + os.close(sentinel_fd) + if not sentinel_present: + return False, "terminal sentinel is unsafe" + except FileNotFoundError: + pass + + tombstones = sorted( + name + for name in os.listdir(runtime_fd) + if name.startswith(".evidence-cleanup-") + ) + if len(tombstones) > 1: + return False, "multiple cleanup tombstones exist" + tombstone_name = tombstones[0] if tombstones else "" + task_fd = -1 + try: + task_fd = open_child_directory(runtime_fd, task_short_id) + descriptors.append(task_fd) + except FileNotFoundError: + pass + if task_fd >= 0 and sentinel_present: + return False, "active runtime conflicts with terminal sentinel" + if task_fd >= 0 and tombstone_name: + return False, "active runtime conflicts with cleanup tombstone" + if task_fd >= 0 and not sentinel_present and not tombstone_name: + try: + candidate_records = _read_ledger_records(task_fd) + except (FileNotFoundError, OSError, ValueError, json.JSONDecodeError): + return False, "active v2 ledger is missing or invalid" + if ( + not candidate_records + or candidate_records[-1].get("record_kind") + != runtime.TERMINAL_SEAL_SCHEMA + ): + return False, "active runtime is not a sealed v2 ledger" + if task_fd < 0 and not tombstone_name and not sentinel_present: + return None + try: + reported_marker = _cleanup_marker( + runtime, + cleanup_report, + task_short_id, + ) + except (ValueError, json.JSONDecodeError) as error: + return False, str(error) + if task_fd >= 0: + if _phase_hook is not None: + _phase_hook("after_task_fd_before_link_revalidation") + _revalidate_directory_link(runtime_fd, task_short_id, task_fd) + records = _read_ledger_records(task_fd) + marker = runtime.validate_terminal_chain( + records, + task_short_id=task_short_id, + ) + reported = runtime.validate_cleanup_report(cleanup_report) + if reported != marker or reported_marker != marker: + return False, "cleanup report marker does not match ledger" + expected_name = ".evidence-cleanup-{}".format( + runtime.canonical_sha256(marker) + ) + if tombstone_name and tombstone_name != expected_name: + return False, "cleanup tombstone name does not match ledger" + os.rename( + task_short_id, + expected_name, + src_dir_fd=runtime_fd, + dst_dir_fd=runtime_fd, + ) + try: + os.fsync(runtime_fd) + except OSError: + os.rename( + expected_name, + task_short_id, + src_dir_fd=runtime_fd, + dst_dir_fd=runtime_fd, + ) + os.fsync(runtime_fd) + raise + renamed_tombstone_fd = open_child_directory(runtime_fd, expected_name) + try: + if _phase_hook is not None: + _phase_hook("after_tombstone_rename") + _revalidate_directory_link( + runtime_fd, + expected_name, + renamed_tombstone_fd, + ) + finally: + os.close(renamed_tombstone_fd) + os.close(task_fd) + descriptors.remove(task_fd) + task_fd = -1 + tombstone_name = expected_name + if task_fd >= 0 and not tombstone_name: + return None + elif not tombstone_name: + if sentinel_present: + return False, "terminal sentinel exists without cleanup tombstone" + return False, "sealed v2 runtime evidence is missing" + + tombstone_fd = open_child_directory(runtime_fd, tombstone_name) + descriptors.append(tombstone_fd) + _revalidate_directory_link(runtime_fd, tombstone_name, tombstone_fd) + _validate_retained_tombstone( + workspace, + runtime, + tombstone_fd, + tombstone_name, + task_short_id, + reported_marker, + ) + if not sentinel_present: + sentinel_fd = os.open( + sentinel_name, + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=runtime_fd, + ) + try: + os.fsync(sentinel_fd) + os.fsync(runtime_fd) + if _phase_hook is not None: + _phase_hook("after_terminal_sentinel_create") + _revalidate_file_link(runtime_fd, sentinel_name, sentinel_fd) + finally: + os.close(sentinel_fd) + return True, "sealed evidence retained for managed worktree deletion" + except (FileNotFoundError, NotADirectoryError, OSError, ValueError) as error: + return False, str(error) + finally: + close_descriptors(*descriptors) + + +def remove_runtime_state( + workspace: Path, + task_short_id: str, + *, + cleanup_report: str | None = None, + _phase_hook=None, +) -> tuple[bool, str]: if not TASK_KEY.fullmatch(task_short_id): return False, "invalid task short ID" + v2_result = _remove_v2_runtime_state( + workspace, + task_short_id, + cleanup_report=cleanup_report, + _phase_hook=_phase_hook, + ) + if v2_result is not None: + return v2_result + descriptors: list[int] = [] try: - root_fd = os.open(workspace, os.O_RDONLY | os.O_DIRECTORY) + root_fd = os.open( + workspace, + os.O_RDONLY + | os.O_DIRECTORY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) descriptors.append(root_fd) kent_fd = open_child_directory(root_fd, ".kent") descriptors.append(kent_fd) @@ -290,25 +1065,49 @@ def remove_runtime_state(workspace: Path, task_short_id: str) -> tuple[bool, str except FileNotFoundError: return True, "no task-local runtime state" descriptors.append(runtime_fd) + if _phase_hook is not None: + _phase_hook("after_runtime_fd_before_lock_open") try: task_fd = open_child_directory(runtime_fd, task_short_id) except FileNotFoundError: return True, "no task-local runtime state" descriptors.append(task_fd) + if _phase_hook is not None: + _phase_hook("after_task_fd_before_link_revalidation") + lock_fd = os.open( + "runtime.lock", + os.O_RDWR + | os.O_CREAT + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=task_fd, + ) + descriptors.append(lock_fd) + fcntl.flock(lock_fd, fcntl.LOCK_EX) allowed = { "evidence-ledger.jsonl", "fix-checkpoint.json", "smoke-checkpoint.json", + "runtime.lock", + "terminal-sentinel", } entries = os.listdir(task_fd) - unexpected = sorted(set(entries) - allowed) + if "terminal-sentinel" in entries: + runtime_contracts() + unexpected = sorted( + item for item in set(entries) - allowed + if not item.startswith(".evidence-cleanup-") + ) if unexpected: return False, f"unexpected runtime entries: {unexpected}" for entry in entries: metadata = os.stat(entry, dir_fd=task_fd, follow_symlinks=False) if not stat.S_ISREG(metadata.st_mode): return False, f"runtime entry is not a regular file: {entry}" + if metadata.st_nlink != 1: + return False, f"runtime entry has multiple links: {entry}" relative = f".kent/runtime/{task_short_id}/{entry}" ignored = run( ["git", "check-ignore", "-q", "--", relative], @@ -327,8 +1126,16 @@ def remove_runtime_state(workspace: Path, task_short_id: str) -> tuple[bool, str ) for entry in entries: os.unlink(entry, dir_fd=task_fd) + os.fsync(task_fd) + if _phase_hook is not None: + if entry == "fix-checkpoint.json": + _phase_hook("after_fix_checkpoint_unlink_fsync") + elif entry == "smoke-checkpoint.json": + _phase_hook("after_smoke_checkpoint_unlink_fsync") + elif entry == "evidence-ledger.jsonl": + _phase_hook("after_ledger_unlink_fsync") os.close(task_fd) - descriptors.pop() + descriptors.remove(task_fd) os.rmdir(task_short_id, dir_fd=runtime_fd) return True, "removed task-local runtime state" except (NotADirectoryError, OSError, ValueError) as error: @@ -338,12 +1145,163 @@ def remove_runtime_state(workspace: Path, task_short_id: str) -> tuple[bool, str def complete(transition: str, report: str, blocker: str = "") -> None: - payload = {"schema_version": RUNTIME_SCHEMA_VERSION, "transition": transition, "cleanup_report": report} + payload = {"transition": transition, "cleanup_report": report} if blocker: payload["blocker_reason"] = blocker print(json.dumps(payload, ensure_ascii=False)) +def delete_managed_worktree( + wrapper: str, + primary: Path, + workspace: Path, + cleanup_session_id: str, + cleanup_report: str, +) -> tuple[bool, str, str]: + flags = ( + os.O_RDONLY + | os.O_DIRECTORY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + primary_fd = os.open(primary, flags) + primary_opened = os.fstat(primary_fd) + workspace_fd = os.open(workspace, flags) + opened = os.fstat(workspace_fd) + expected_tombstone = None + support_module = Path(__file__).with_name("workflow_runtime_contracts.py") + if support_module.is_file() and not support_module.is_symlink(): + runtime = runtime_contracts() + expected_tombstone = ".evidence-cleanup-{}".format( + runtime.canonical_sha256( + runtime.validate_cleanup_report(cleanup_report) + ) + ) + try: + primary_current = os.stat(primary, follow_symlinks=False) + if ( + not stat.S_ISDIR(primary_current.st_mode) + or primary_current.st_dev != primary_opened.st_dev + or primary_current.st_ino != primary_opened.st_ino + ): + raise ValueError("primary checkout changed before deletion") + os.chdir(primary) + deletion = run( + [ + wrapper, + "delete", + "--session", + cleanup_session_id, + "--delete-branch", + "--json", + str(workspace), + ], + cwd=primary, + timeout=120, + check=False, + ) + if deletion.returncode != 0: + detail = deletion.stderr.strip() or deletion.stdout.strip() or "no output" + return ( + False, + f"Janitor could not remove {workspace}: {detail}", + "Kent-managed worktree deletion failed after safety checks.", + ) + try: + deletion_payload = json_object(deletion.stdout, "Kent worktree delete") + except RuntimeError as error: + return ( + False, + f"Janitor could not confirm removal of {workspace}: {error}", + "Kent worktree deletion returned no trustworthy completion result.", + ) + deletion_kind = str(deletion_payload.get("kind") or "") + if deletion_kind == "scheduled": + scheduled = deletion_payload.get("scheduled") + operation_id = ( + str(scheduled.get("operation_id") or "") + if isinstance(scheduled, dict) + else "" + ) + return ( + False, + f"Janitor preserved {workspace}: Kent scheduled worktree " + f"deletion operation {operation_id or ''} but did " + "not confirm completion.", + "Resume Cleanup so the session can leave the task worktree, " + "close task-owned background shells, and retry Janitor.", + ) + if deletion_kind != "completed": + return ( + False, + f"Janitor preserved {workspace}: unexpected Kent worktree " + f"delete result {deletion_kind or ''}.", + "Kent did not return a completed worktree deletion result.", + ) + primary_current = os.stat(primary, follow_symlinks=False) + if ( + not stat.S_ISDIR(primary_current.st_mode) + or primary_current.st_dev != primary_opened.st_dev + or primary_current.st_ino != primary_opened.st_ino + ): + return ( + False, + f"Janitor preserved {workspace}: the primary checkout path " + "was replaced while Kent deletion was running.", + "Primary checkout identity changed; no branch or remote " + "cleanup was attempted.", + ) + try: + current = os.stat(workspace, follow_symlinks=False) + except FileNotFoundError: + current = None + if current is not None and ( + not stat.S_ISDIR(current.st_mode) + or current.st_dev != opened.st_dev + or current.st_ino != opened.st_ino + ): + return ( + False, + f"Janitor preserved {workspace}: the managed worktree path " + "was replaced while Kent deletion was running.", + "The original managed worktree descriptor no longer matches " + "the path; no branch or remote cleanup was attempted.", + ) + registered_after_delete = git_registers_worktree(primary, workspace) + if current is not None or registered_after_delete: + if current is not None: + runtime_path = workspace / ".kent" / "runtime" + tombstones = ( + sorted( + path.name + for path in runtime_path.iterdir() + if path.name.startswith(".evidence-cleanup-") + ) + if runtime_path.is_dir() + else [] + ) + evidence_note = ( + "terminal cleanup tombstone remains; retry cleanup" + if expected_tombstone and expected_tombstone in tombstones + else "terminal cleanup tombstone is absent; evidence loss " + "is ambiguous" + ) + else: + evidence_note = "Git still registers the managed worktree" + return ( + False, + f"Janitor preserved {workspace}: Kent reported completed " + "deletion but the worktree path or Git registration still " + f"exists ({evidence_note}).", + "Kent worktree deletion postconditions failed; no branch or " + "remote cleanup was attempted.", + ) + return True, "", "" + finally: + os.close(workspace_fd) + os.close(primary_fd) + + def main() -> int: try: payload = json.load(sys.stdin) @@ -362,8 +1320,10 @@ def main() -> int: if not workspace.is_dir(): complete( - "task_janitor_done", - f"{previous_report}\nJanitor preserved missing workspace: {workspace}", + "task_janitor_blocked", + f"{previous_report}\nJanitor cannot infer deletion of missing workspace: {workspace}", + "Only the same cleanup invocation may acknowledge a deleted " + "managed workspace after verifying Git registration and evidence.", ) return 0 @@ -407,12 +1367,14 @@ def main() -> int: runtime_removed, runtime_report = remove_runtime_state( workspace, task_short_id, + cleanup_report=previous_report, ) runtime_action = "removed" if runtime_removed else "preserved" complete( - "task_janitor_done", + "task_janitor_done" if runtime_removed else "task_janitor_blocked", f"{previous_report}\nJanitor kept the primary checkout and " f"{runtime_action} runtime state: {runtime_report}.", + "" if runtime_removed else runtime_report, ) return 0 @@ -484,6 +1446,21 @@ def main() -> int: ) return 0 + managed_v2 = _prepare_v2_managed_runtime_state( + workspace, + task_short_id, + cleanup_report=previous_report, + ) + if managed_v2 is not None and not managed_v2[0]: + complete( + "task_janitor_blocked", + f"{previous_report}\nJanitor preserved {workspace}: " + f"{managed_v2[1]}.", + "Sealed terminal evidence could not be retained before Kent " + "worktree deletion; retry Cleanup without deleting the worktree.", + ) + return 0 + wrapper = os.environ.get( "KENT_WORKTREE_WRAPPER", str(Path.home() / ".kent" / "bin" / "kent-worktree"), @@ -515,73 +1492,18 @@ def main() -> int: remote_oid = remote_branch_oid(primary, branch) if branch else "" - os.chdir(primary) - deletion = run( - [ - wrapper, - "delete", - "--session", - cleanup_session_id, - "--delete-branch", - "--json", - str(workspace), - ], - cwd=primary, - timeout=120, - check=False, + deletion_ok, deletion_report, deletion_blocker = delete_managed_worktree( + wrapper, + primary, + workspace, + cleanup_session_id, + previous_report, ) - if deletion.returncode != 0: - detail = deletion.stderr.strip() or deletion.stdout.strip() or "no output" - complete( - "task_janitor_blocked", - f"{previous_report}\nJanitor could not remove {workspace}: {detail}", - "Kent-managed worktree deletion failed after safety checks.", - ) - return 0 - - try: - deletion_payload = json_object(deletion.stdout, "Kent worktree delete") - except RuntimeError as error: - complete( - "task_janitor_blocked", - f"{previous_report}\nJanitor could not confirm removal of " - f"{workspace}: {error}", - "Kent worktree deletion returned no trustworthy completion result.", - ) - return 0 - deletion_kind = str(deletion_payload.get("kind") or "") - if deletion_kind == "scheduled": - scheduled = deletion_payload.get("scheduled") - operation_id = ( - str(scheduled.get("operation_id") or "") - if isinstance(scheduled, dict) - else "" - ) - complete( - "task_janitor_blocked", - f"{previous_report}\nJanitor preserved {workspace}: Kent scheduled " - f"worktree deletion operation {operation_id or ''} but did " - "not confirm completion.", - "Resume Cleanup so the session can leave the task worktree, close " - "task-owned background shells, and retry Janitor.", - ) - return 0 - if deletion_kind != "completed": - complete( - "task_janitor_blocked", - f"{previous_report}\nJanitor preserved {workspace}: unexpected Kent " - f"worktree delete result {deletion_kind or ''}.", - "Kent did not return a completed worktree deletion result.", - ) - return 0 - if workspace.exists() or git_registers_worktree(primary, workspace): + if not deletion_ok: complete( "task_janitor_blocked", - f"{previous_report}\nJanitor preserved {workspace}: Kent reported " - "completed deletion but the worktree path or Git registration " - "still exists.", - "Kent worktree deletion postconditions failed; no branch or remote " - "cleanup was attempted.", + f"{previous_report}\n{deletion_report}", + deletion_blocker, ) return 0 diff --git a/.kent/scripts/workflow-verify-report b/.kent/scripts/workflow-verify-report index edd4fe86..bb61e86d 100755 --- a/.kent/scripts/workflow-verify-report +++ b/.kent/scripts/workflow-verify-report @@ -1,93 +1,1182 @@ -#!/usr/bin/env bash -set -uo pipefail - -verify_script="${KENT_WORKFLOW_VERIFY_SCRIPT:-./.kent/scripts/workflow-compile-verify}" -verify_log="${KENT_WORKFLOW_VERIFY_LOG:-build/kent-workflow/compile-dev-debug.log}" -input="$(jq -c . 2>/dev/null || true)" - -emit_report() { - local status="$1" - local commentary="$2" - local report="$3" - - jq -nc \ - --arg transition "deterministic_verify_reported" \ - --arg commentary "$commentary" \ - --arg verification_status "$status" \ - --arg verification_report "$report" \ - --arg verification_schema_version "4" \ - '{ - transition: $transition, - commentary: $commentary, - verification_status: $verification_status, - verification_report: $verification_report, - verification_schema_version: ($verification_schema_version | tonumber) - }' +#!/usr/bin/env python3 +"""Run the project verifier and emit a bounded, content-addressed report.""" + +from __future__ import annotations + +import hashlib +import errno +import json +import os +from pathlib import Path +import selectors +import signal +import stat +import subprocess +import sys +import time +from typing import Any + + +INPUT_LIMIT = 1024 * 1024 +TIMEOUT_SECONDS = 7200 +OUTPUT_LIMIT = 4 * 1024 * 1024 +REPORT_SCHEMA = "workflow-verification-report-v2" +CHILD_NAME = "workflow-compile-verify" +REPORT_CODES = { + "passed", + "verification_failed", + "verification_blocked", + "input_invalid", + "workspace_invalid", + "verifier_missing", + "verifier_unsafe", + "log_path_unsafe", + "child_timeout", + "child_exit_nonzero", + "child_output_invalid", + "log_limit_exceeded", + "internal_error", } +OPTIONAL_ENVIRONMENT = ( + "HOME", + "JAVA_HOME", + "ANDROID_HOME", + "ANDROID_SDK_ROOT", + "GRADLE_USER_HOME", + "GOROOT", + "GOPATH", + "GOMODCACHE", + "GOCACHE", + "XDG_CACHE_HOME", +) +FIXED_PATHS = ( + "/opt/homebrew/bin", + "/usr/local/bin", + "/home/linuxbrew/.linuxbrew/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", +) + + +class VerificationFailure(ValueError): + def __init__(self, code: str) -> None: + super().__init__(code) + self.code = code + + +def runtime_contracts(): + import importlib.util + + sibling = Path(__file__).with_name("workflow_runtime_contracts.py") + if sibling.is_symlink() or not sibling.is_file(): + raise ValueError("runtime contract support module is unsafe") + spec = importlib.util.spec_from_file_location( + "workflow_runtime_contracts", + sibling, + ) + if spec is None or spec.loader is None: + raise ValueError("runtime contract support module is unavailable") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _unique_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON field") + result[key] = value + return result + + +def _report( + runtime, + code: str, + *, + log_path: str | None = None, + log_sha256: str | None = None, + exit_code: int | None = None, +) -> dict[str, Any]: + value = runtime.validate_verification_report( + { + "schema": REPORT_SCHEMA, + "code": code if code in REPORT_CODES else "internal_error", + "log_path": log_path, + "log_sha256": log_sha256, + "exit_code": exit_code, + } + ) + return value + + +def _emit(runtime, report: dict[str, Any]) -> int: + status = runtime.classify_verification_report(report) + print( + json.dumps( + { + "transition": "deterministic_verify_reported", + "verification_status": status, + "verification_report": json.dumps( + report, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ), + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + ) + return 0 + + +def _check_directory( + metadata: os.stat_result, + *, + failure_code: str = "verifier_unsafe", +) -> None: + if ( + not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != os.getuid() + or metadata.st_mode & 0o022 + ): + raise VerificationFailure(failure_code) + + +def _check_file( + metadata: os.stat_result, + *, + executable: bool = False, + owner_only: bool = False, + failure_code: str = "verifier_unsafe", +) -> None: + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.getuid() + or metadata.st_nlink != 1 + or metadata.st_mode & 0o022 + or (owner_only and metadata.st_mode & 0o077) + or (executable and not metadata.st_mode & stat.S_IXUSR) + ): + raise VerificationFailure(failure_code) + + +def _revalidate_directory( + parent: int, + name: str, + descriptor: int, + *, + failure_code: str = "verifier_unsafe", +) -> None: + opened = os.fstat(descriptor) + try: + current = os.stat(name, dir_fd=parent, follow_symlinks=False) + except OSError as error: + raise VerificationFailure(failure_code) from error + if ( + not stat.S_ISDIR(current.st_mode) + or current.st_dev != opened.st_dev + or current.st_ino != opened.st_ino + or current.st_uid != os.getuid() + or current.st_mode & 0o022 + ): + raise VerificationFailure(failure_code) + + +def _revalidate_file( + parent: int, + name: str, + descriptor: int, + *, + executable: bool = False, + owner_only: bool = False, + failure_code: str = "verifier_unsafe", +) -> None: + opened = os.fstat(descriptor) + try: + current = os.stat(name, dir_fd=parent, follow_symlinks=False) + except OSError as error: + raise VerificationFailure(failure_code) from error + _check_file( + current, + executable=executable, + owner_only=owner_only, + failure_code=failure_code, + ) + if current.st_dev != opened.st_dev or current.st_ino != opened.st_ino: + raise VerificationFailure(failure_code) + + +def _open_directory( + parent: int, + name: str, + *, + create: bool, + failure_code: str | None = None, +) -> int: + flags = ( + os.O_RDONLY + | os.O_DIRECTORY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + if create: + try: + os.mkdir(name, 0o700, dir_fd=parent) + os.fsync(parent) + except FileExistsError: + pass + except OSError as error: + if failure_code is None: + raise + raise VerificationFailure(failure_code) from error + try: + return_descriptor = os.open(name, flags, dir_fd=parent) + except OSError as error: + if failure_code is None: + raise + raise VerificationFailure(failure_code) from error + try: + _check_directory( + os.fstat(return_descriptor), + failure_code=failure_code or "verifier_unsafe", + ) + return return_descriptor + except BaseException: + os.close(return_descriptor) + raise + + +def _revalidate_log_chain( + root_fd: int, + build_fd: int, + workflow_fd: int, +) -> None: + try: + _revalidate_directory( + root_fd, + "build", + build_fd, + failure_code="log_path_unsafe", + ) + _revalidate_directory( + build_fd, + "kent-workflow", + workflow_fd, + failure_code="log_path_unsafe", + ) + except OSError as error: + raise VerificationFailure("log_path_unsafe") from error + + +def _fd_path(descriptor: int) -> str: + if not os.path.isdir("/dev/fd"): + raise VerificationFailure("verifier_unsafe") + path = f"/dev/fd/{descriptor}" + try: + opened = os.fstat(descriptor) + except OSError as error: + raise VerificationFailure("verifier_unsafe") from error + opened_identity = (opened.st_dev, opened.st_ino) + try: + resolved = os.stat(path) + except OSError as error: + if error.errno != errno.EBADF: + raise VerificationFailure("verifier_unsafe") from error + try: + current = os.fstat(descriptor) + except OSError as current_error: + raise VerificationFailure("verifier_unsafe") from current_error + if (current.st_dev, current.st_ino) != opened_identity: + raise VerificationFailure("verifier_unsafe") + try: + resolved = os.stat(path) + except OSError as retry_error: + raise VerificationFailure("verifier_unsafe") from retry_error + try: + final = os.fstat(descriptor) + except OSError as error: + raise VerificationFailure("verifier_unsafe") from error + if (final.st_dev, final.st_ino) != opened_identity: + raise VerificationFailure("verifier_unsafe") + if resolved.st_ino != opened.st_ino: + raise VerificationFailure("verifier_unsafe") + return path + + +def _open_verifier(workspace_fd: int) -> tuple[int, int, int]: + descriptors: list[int] = [] + try: + try: + kent_fd = _open_directory(workspace_fd, ".kent", create=False) + descriptors.append(kent_fd) + scripts_fd = _open_directory(kent_fd, "scripts", create=False) + descriptors.append(scripts_fd) + except FileNotFoundError as error: + raise VerificationFailure("verifier_missing") from error + except OSError as error: + raise VerificationFailure("verifier_unsafe") from error + flags = ( + os.O_RDONLY + | getattr(os, "O_NONBLOCK", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + try: + child_fd = os.open(CHILD_NAME, flags, dir_fd=scripts_fd) + except FileNotFoundError as error: + raise VerificationFailure("verifier_missing") from error + except OSError as error: + raise VerificationFailure("verifier_unsafe") from error + descriptors.append(child_fd) + _check_file(os.fstat(child_fd), executable=True) + _revalidate_file( + scripts_fd, + CHILD_NAME, + child_fd, + executable=True, + ) + return kent_fd, scripts_fd, child_fd + except BaseException: + for descriptor in reversed(descriptors): + try: + os.close(descriptor) + except OSError: + pass + raise + + +def _valid_environment_value(value: str) -> bool: + try: + encoded = value.encode("utf-8") + except UnicodeEncodeError: + return False + return ( + bool(value) + and b"\x00" not in encoded + and len(encoded) <= 4096 + and Path(value).is_absolute() + and Path(value).is_dir() + ) + + +def _git_command() -> str: + for directory in FIXED_PATHS: + candidate = Path(directory) / "git" + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate) + raise VerificationFailure("internal_error") + + +def replacement_environment(tmp_fd: int) -> dict[str, str]: + environment = { + "CI": "1", + "LANG": "C", + "LC_ALL": "C", + "GIT_TERMINAL_PROMPT": "0", + "GCM_INTERACTIVE": "never", + "TMPDIR": _fd_path(tmp_fd), + } + valid: dict[str, str] = {} + for name in OPTIONAL_ENVIRONMENT: + value = os.environ.get(name) + if value is not None and _valid_environment_value(value): + valid[name] = value + path_entries: list[str] = [] + for name in ("JAVA_HOME", "GOROOT", "GOPATH"): + value = valid.get(name) + if value: + candidate = str(Path(value) / "bin") + if Path(candidate).is_dir(): + path_entries.append(candidate) + path_entries.extend( + candidate for candidate in FIXED_PATHS if Path(candidate).is_dir() + ) + environment["PATH"] = os.pathsep.join(dict.fromkeys(path_entries)) + environment.update(valid) + return environment + + +def _write_all(descriptor: int, content: bytes) -> None: + view = memoryview(content) + while view: + written = os.write(descriptor, view) + if written <= 0: + raise VerificationFailure("internal_error") + view = view[written:] + + +def _frame(stdout: bytes, stderr: bytes, limit: int) -> tuple[bytes, bool]: + def make(out: bytes, err: bytes) -> bytes: + return ( + b"stdout:" + + str(len(out)).encode("ascii") + + b"\n" + + out + + b"stderr:" + + str(len(err)).encode("ascii") + + b"\n" + + err + ) + + full = make(stdout, stderr) + if len(full) <= limit: + return full, False + low, high = 0, len(stdout) + len(stderr) + best = b"stdout:0\nstderr:0\n" + while low <= high: + total = (low + high) // 2 + out_length = min(len(stdout), total) + err_length = min(len(stderr), total - out_length) + candidate = make(stdout[:out_length], stderr[:err_length]) + if len(candidate) <= limit: + best = candidate + low = total + 1 + else: + high = total - 1 + return best, True + + +def _terminate_group(process: subprocess.Popen[bytes]) -> None: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + + +def _child_command(child_path: str, child_fd: int) -> list[str]: + try: + sample = os.pread(child_fd, 128, 0) + except OSError: + sample = b"" + if os.access(child_path, os.X_OK): + return [child_path] + if sample.startswith(b"#!") and b"python" in sample.splitlines()[0]: + return [sys.executable, child_path] + if sample.startswith(b"#!") and any( + shell in sample.splitlines()[0] + for shell in (b"/sh", b"/bash", b"/zsh") + ): + return ["/bin/sh", child_path] + return [child_path] + + +def _child_descriptor(descriptor: int) -> int: + duplicate = os.dup(descriptor) + while duplicate < 3: + next_duplicate = os.dup(descriptor) + os.close(duplicate) + duplicate = next_duplicate + return duplicate + + +def _run_child_fd( + child_fd: int, + child_parent_fd: int | None, + child_name: str | None, + workspace: Path, + tmp_fd: int, + input_payload: bytes, + *, + _phase_hook=None, +) -> tuple[int | None, bytes, bytes, str | None]: + _fd_path(child_fd) + _fd_path(tmp_fd) + input_fd = os.open( + ".verification-input", + os.O_RDWR + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=tmp_fd, + ) + child_exec_fd = -1 + tmp_exec_fd = -1 + try: + _write_all(input_fd, input_payload) + os.fsync(input_fd) + os.lseek(input_fd, 0, os.SEEK_SET) + if child_parent_fd is not None and child_name is not None: + _revalidate_file( + child_parent_fd, + child_name, + child_fd, + executable=True, + ) + _check_file(os.fstat(child_fd), executable=True) + if _phase_hook is not None: + _phase_hook("after_verifier_open_before_child") + if child_parent_fd is not None and child_name is not None: + _revalidate_file( + child_parent_fd, + child_name, + child_fd, + executable=True, + ) + child_exec_fd = _child_descriptor(child_fd) + tmp_exec_fd = _child_descriptor(tmp_fd) + child_path = _fd_path(child_exec_fd) + _fd_path(tmp_exec_fd) + environment = replacement_environment(tmp_exec_fd) + input_stream = os.fdopen(os.dup(input_fd), "rb") + try: + process = subprocess.Popen( + _child_command(child_path, child_exec_fd), + cwd=str(workspace), + env=environment, + stdin=input_stream, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + pass_fds=(child_exec_fd, tmp_exec_fd), + start_new_session=True, + ) + finally: + input_stream.close() + assert process.stdout is not None + assert process.stderr is not None + for stream in (process.stdout, process.stderr): + os.set_blocking(stream.fileno(), False) + selector = selectors.DefaultSelector() + selector.register(process.stdout, selectors.EVENT_READ, "stdout") + selector.register(process.stderr, selectors.EVENT_READ, "stderr") + buffers = {"stdout": bytearray(), "stderr": bytearray()} + failure: str | None = None + deadline = time.monotonic() + TIMEOUT_SECONDS + while selector.get_map() or process.poll() is None: + remaining = deadline - time.monotonic() + if remaining <= 0 and failure is None: + failure = "timeout" + _terminate_group(process) + timeout = max(0.0, min(remaining, 0.25)) + for key, _ in selector.select(timeout): + chunk = os.read(key.fileobj.fileno(), 65536) + if not chunk: + selector.unregister(key.fileobj) + key.fileobj.close() + continue + total = sum(len(value) for value in buffers.values()) + remaining_output = OUTPUT_LIMIT - total + if len(chunk) > remaining_output: + if remaining_output > 0: + buffers[key.data].extend(chunk[:remaining_output]) + failure = "output_limit" + _terminate_group(process) + break + buffers[key.data].extend(chunk) + if failure is not None: + for key in list(selector.get_map().values()): + selector.unregister(key.fileobj) + key.fileobj.close() + break + if failure is None: + process.wait() + return ( + process.returncode, + bytes(buffers["stdout"]), + bytes(buffers["stderr"]), + failure, + ) + except VerificationFailure: + raise + except (OSError, ValueError): + return None, b"", b"", "internal" + finally: + try: + os.unlink(".verification-input", dir_fd=tmp_fd) + except OSError: + pass + try: + os.close(input_fd) + except OSError: + pass + for descriptor in (child_exec_fd, tmp_exec_fd): + try: + os.close(descriptor) + except OSError: + pass + + +def bounded_child( + executable: Path, + workspace: Path, + payload: bytes, + *, + _phase_hook=None, +) -> tuple[int | None, bytes, bytes, str | None]: + root_fd = os.open( + workspace, + os.O_RDONLY + | os.O_DIRECTORY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) + descriptors = [root_fd] + tmp_name = "" + kent_fd = -1 + try: + build_fd = _open_directory(root_fd, "build", create=True) + kent_fd = _open_directory(build_fd, "kent-workflow", create=True) + tmp_name = f".verify-tmp-{os.getpid()}-{time.time_ns()}" + os.mkdir(tmp_name, 0o700, dir_fd=kent_fd) + os.fsync(kent_fd) + tmp_fd = _open_directory(kent_fd, tmp_name, create=False) + descriptors.extend((build_fd, kent_fd, tmp_fd)) + child_parent_fd = os.open( + executable.parent, + os.O_RDONLY + | os.O_DIRECTORY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) + descriptors.append(child_parent_fd) + child_fd = os.open( + executable, + os.O_RDONLY + | getattr(os, "O_NONBLOCK", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) + descriptors.append(child_fd) + _check_file(os.fstat(child_fd), executable=True) + if _phase_hook is not None: + _phase_hook("after_log_temp_open_before_child") + return _run_child_fd( + child_fd, + child_parent_fd, + executable.name, + workspace, + tmp_fd, + payload, + _phase_hook=_phase_hook, + ) + finally: + if tmp_name and kent_fd >= 0: + try: + os.rmdir(tmp_name, dir_fd=kent_fd) + os.fsync(kent_fd) + except OSError: + pass + for descriptor in reversed(descriptors): + try: + os.close(descriptor) + except OSError: + pass + + +def _prechild_log_gate( + workspace: Path, + final_relative: str | None = None, +) -> None: + probe = "build/kent-workflow/.verification-report-probe" + git = _git_command() + paths = [probe] + if final_relative is not None: + paths.append(final_relative) + for path in paths: + ignored = subprocess.run( + [ + git, + "-C", + str(workspace), + "check-ignore", + "-q", + "--no-index", + "--", + path, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + tracked_path = subprocess.run( + [ + git, + "-C", + str(workspace), + "ls-files", + "--error-unmatch", + "--", + path, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if ignored.returncode != 0 or tracked_path.returncode == 0: + raise VerificationFailure("log_path_unsafe") + tracked = subprocess.run( + [ + git, + "-C", + str(workspace), + "ls-files", + "--error-unmatch", + "--", + "build/kent-workflow", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if tracked.returncode == 0: + raise VerificationFailure("log_path_unsafe") + + +def _open_log_temp(kent_fd: int) -> tuple[int, str]: + name = f".verification-report-{os.getpid()}-{time.time_ns()}.tmp" + try: + descriptor = os.open( + name, + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=kent_fd, + ) + except OSError as error: + raise VerificationFailure("log_path_unsafe") from error + try: + _check_file( + os.fstat(descriptor), + owner_only=True, + failure_code="log_path_unsafe", + ) + os.fsync(kent_fd) + _revalidate_file( + kent_fd, + name, + descriptor, + owner_only=True, + failure_code="log_path_unsafe", + ) + return descriptor, name + except VerificationFailure: + os.close(descriptor) + raise + except OSError as error: + os.close(descriptor) + raise VerificationFailure("log_path_unsafe") from error + + +def _install_log( + workspace: Path, + root_fd: int, + build_fd: int, + kent_fd: int, + temporary_fd: int, + temporary_name: str, + content: bytes, +) -> tuple[str, str]: + digest = hashlib.sha256(content).hexdigest() + filename = f"verification-report-{digest}.log" + relative = f"build/kent-workflow/{filename}" + _revalidate_log_chain(root_fd, build_fd, kent_fd) + _prechild_log_gate(workspace, relative) + _revalidate_log_chain(root_fd, build_fd, kent_fd) + _revalidate_file( + kent_fd, + temporary_name, + temporary_fd, + failure_code="log_path_unsafe", + ) + _write_all(temporary_fd, content) + os.fsync(temporary_fd) + _revalidate_log_chain(root_fd, build_fd, kent_fd) + _revalidate_file( + kent_fd, + temporary_name, + temporary_fd, + failure_code="log_path_unsafe", + ) + _revalidate_log_chain(root_fd, build_fd, kent_fd) + try: + existing_fd = os.open( + filename, + os.O_RDONLY + | getattr(os, "O_NONBLOCK", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + dir_fd=kent_fd, + ) + except FileNotFoundError: + existing_fd = -1 + except OSError as error: + raise VerificationFailure("log_path_unsafe") from error + if existing_fd >= 0: + try: + _revalidate_log_chain(root_fd, build_fd, kent_fd) + _check_file( + os.fstat(existing_fd), + owner_only=True, + failure_code="log_path_unsafe", + ) + if os.fstat(existing_fd).st_mode & 0o777 != 0o600: + raise VerificationFailure("log_path_unsafe") + _revalidate_file( + kent_fd, + filename, + existing_fd, + owner_only=True, + failure_code="log_path_unsafe", + ) + os.lseek(existing_fd, 0, os.SEEK_SET) + existing = os.read(existing_fd, len(content) + 1) + if existing != content: + raise VerificationFailure("log_path_unsafe") + finally: + os.close(existing_fd) + _revalidate_log_chain(root_fd, build_fd, kent_fd) + try: + os.unlink(temporary_name, dir_fd=kent_fd) + os.fsync(kent_fd) + except OSError as error: + raise VerificationFailure("log_path_unsafe") from error + _revalidate_log_chain(root_fd, build_fd, kent_fd) + return relative, digest + try: + _revalidate_log_chain(root_fd, build_fd, kent_fd) + os.rename( + temporary_name, + filename, + src_dir_fd=kent_fd, + dst_dir_fd=kent_fd, + ) + os.fsync(kent_fd) + except OSError as error: + raise VerificationFailure("log_path_unsafe") from error + _revalidate_log_chain(root_fd, build_fd, kent_fd) + final_fd = -1 + try: + final_fd = os.open( + filename, + os.O_RDONLY + | getattr(os, "O_NONBLOCK", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + dir_fd=kent_fd, + ) + _check_file( + os.fstat(final_fd), + owner_only=True, + failure_code="log_path_unsafe", + ) + if os.fstat(final_fd).st_mode & 0o777 != 0o600: + raise VerificationFailure("log_path_unsafe") + _revalidate_file( + kent_fd, + filename, + final_fd, + owner_only=True, + failure_code="log_path_unsafe", + ) + os.lseek(final_fd, 0, os.SEEK_SET) + if os.read(final_fd, len(content) + 1) != content: + raise VerificationFailure("log_path_unsafe") + except VerificationFailure: + raise + except OSError as error: + raise VerificationFailure("log_path_unsafe") from error + finally: + if final_fd >= 0: + os.close(final_fd) + _revalidate_log_chain(root_fd, build_fd, kent_fd) + return relative, digest + + +def write_log( + workspace: Path, + content: bytes, + *, + _phase_hook=None, +) -> tuple[str, str]: + root_fd = os.open( + workspace, + os.O_RDONLY + | os.O_DIRECTORY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) + descriptors = [root_fd] + temporary_name = "" + try: + build_fd = _open_directory( + root_fd, + "build", + create=True, + failure_code="log_path_unsafe", + ) + kent_fd = _open_directory( + build_fd, + "kent-workflow", + create=True, + failure_code="log_path_unsafe", + ) + descriptors.extend((build_fd, kent_fd)) + if _phase_hook is not None: + _phase_hook("after_log_dir_open_before_revalidation") + _revalidate_log_chain(root_fd, build_fd, kent_fd) + _prechild_log_gate(workspace) + temporary_fd, temporary_name = _open_log_temp(kent_fd) + descriptors.append(temporary_fd) + return _install_log( + workspace, + root_fd, + build_fd, + kent_fd, + temporary_fd, + temporary_name, + content, + ) + finally: + if temporary_name: + try: + os.unlink(temporary_name, dir_fd=descriptors[2]) + os.fsync(descriptors[2]) + except (IndexError, OSError): + pass + for descriptor in reversed(descriptors): + try: + os.close(descriptor) + except OSError: + pass + + +def _workspace_root(configured: Any) -> Path: + if not isinstance(configured, str) or not configured: + raise VerificationFailure("input_invalid") + try: + requested = Path(configured).expanduser().resolve() + current = Path.cwd().resolve() + except (OSError, RuntimeError, ValueError) as error: + raise VerificationFailure("workspace_invalid") from error + if requested != current: + raise VerificationFailure("workspace_invalid") + result = subprocess.run( + [_git_command(), "-C", str(current), "rev-parse", "--show-toplevel"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + check=False, + ) + if result.returncode != 0: + raise VerificationFailure("workspace_invalid") + try: + git_root = Path(result.stdout.strip()).resolve() + except (OSError, RuntimeError, ValueError) as error: + raise VerificationFailure("workspace_invalid") from error + if git_root != current: + raise VerificationFailure("workspace_invalid") + return current + + +def _parse_child(stdout: bytes) -> str: + try: + value = json.loads( + stdout.decode("utf-8"), + object_pairs_hook=_unique_pairs, + ) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as error: + raise VerificationFailure("child_output_invalid") from error + if not isinstance(value, dict) or set(value) != {"transition"}: + raise VerificationFailure("child_output_invalid") + transition = value["transition"] + if transition not in {"passed", "failed", "blocked"}: + raise VerificationFailure("child_output_invalid") + return transition + + +def run_verification(payload: dict[str, Any], *, _phase_hook=None) -> dict[str, Any]: + runtime = runtime_contracts() + try: + workspace = _workspace_root(payload.get("workspace_path")) + root_fd = os.open( + workspace, + os.O_RDONLY + | os.O_DIRECTORY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) + except VerificationFailure as error: + return _report(runtime, error.code) + except OSError: + return _report(runtime, "internal_error") + descriptors = [root_fd] + temporary_name = "" + workflow_fd = -1 + tmp_name = "" + try: + kent_fd, scripts_fd, child_fd = _open_verifier(root_fd) + descriptors.extend((kent_fd, scripts_fd, child_fd)) + build_fd = _open_directory( + root_fd, + "build", + create=True, + failure_code="log_path_unsafe", + ) + workflow_fd = _open_directory( + build_fd, + "kent-workflow", + create=True, + failure_code="log_path_unsafe", + ) + tmp_name = f".verify-tmp-{os.getpid()}-{time.time_ns()}" + try: + os.mkdir(tmp_name, 0o700, dir_fd=workflow_fd) + os.fsync(workflow_fd) + except OSError as error: + raise VerificationFailure("log_path_unsafe") from error + tmp_fd = _open_directory( + workflow_fd, + tmp_name, + create=False, + failure_code="log_path_unsafe", + ) + descriptors.extend((build_fd, workflow_fd, tmp_fd)) + if _phase_hook is not None: + _phase_hook("after_log_dir_open_before_revalidation") + _revalidate_log_chain(root_fd, build_fd, workflow_fd) + _prechild_log_gate(workspace) + temporary_fd, temporary_name = _open_log_temp(workflow_fd) + descriptors.append(temporary_fd) + if _phase_hook is not None: + _phase_hook("after_log_temp_open_before_child") + try: + _revalidate_log_chain(root_fd, build_fd, workflow_fd) + except VerificationFailure as error: + raise VerificationFailure("log_path_unsafe") from error + exit_code, stdout, stderr, failure = _run_child_fd( + child_fd, + scripts_fd, + CHILD_NAME, + workspace, + tmp_fd, + json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8"), + _phase_hook=_phase_hook, + ) + _revalidate_log_chain(root_fd, build_fd, workflow_fd) + framed, frame_overflow = _frame(stdout, stderr, OUTPUT_LIMIT) + if failure == "internal": + raise VerificationFailure("internal_error") + log_path, log_digest = _install_log( + workspace, + root_fd, + build_fd, + workflow_fd, + temporary_fd, + temporary_name, + framed, + ) + temporary_name = "" + if failure == "timeout": + code = "child_timeout" + elif failure == "output_limit" or frame_overflow: + code = "log_limit_exceeded" + elif exit_code not in (0, None): + code = "child_exit_nonzero" + else: + transition = _parse_child(stdout) + code = { + "passed": "passed", + "failed": "verification_failed", + "blocked": "verification_blocked", + }[transition] + return _report( + runtime, + code, + log_path=log_path, + log_sha256=log_digest, + exit_code=exit_code, + ) + except VerificationFailure as error: + return _report(runtime, error.code) + except FileNotFoundError: + return _report(runtime, "verifier_missing") + except (OSError, ValueError, subprocess.SubprocessError): + return _report(runtime, "internal_error") + finally: + if temporary_name: + try: + os.unlink(temporary_name, dir_fd=workflow_fd) + os.fsync(workflow_fd) + except (IndexError, OSError): + pass + if tmp_name and workflow_fd >= 0: + try: + os.rmdir(tmp_name, dir_fd=workflow_fd) + os.fsync(workflow_fd) + except OSError: + pass + for descriptor in reversed(descriptors): + try: + os.close(descriptor) + except OSError: + pass + + +def main() -> int: + try: + runtime = runtime_contracts() + except Exception: + print( + json.dumps( + { + "transition": "deterministic_verify_reported", + "verification_status": "blocked", + "verification_report": json.dumps( + { + "schema": REPORT_SCHEMA, + "code": "internal_error", + "log_path": None, + "log_sha256": None, + "exit_code": None, + }, + sort_keys=True, + separators=(",", ":"), + ), + }, + separators=(",", ":"), + ) + ) + return 0 + try: + raw = sys.stdin.buffer.read(INPUT_LIMIT + 1) + if len(raw) > INPUT_LIMIT: + raise VerificationFailure("input_invalid") + payload = json.loads( + raw.decode("utf-8"), + object_pairs_hook=_unique_pairs, + ) + if not isinstance(payload, dict): + raise VerificationFailure("input_invalid") + return _emit(runtime, run_verification(payload)) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError): + return _emit(runtime, _report(runtime, "input_invalid")) + except VerificationFailure as error: + return _emit(runtime, _report(runtime, error.code)) + except Exception: + return _emit(runtime, _report(runtime, "internal_error")) + -if [[ -z "$input" ]]; then - emit_report "blocked" \ - "Verification wrapper received invalid workflow input." \ - "Input was not valid JSON." - exit 0 -fi - -if [[ ! -x "$verify_script" ]]; then - emit_report "blocked" \ - "Verification executable is unavailable." \ - "Command unavailable: $verify_script" - exit 0 -fi - -stdout_file="$(mktemp)" -stderr_file="$(mktemp)" -trap 'rm -f "$stdout_file" "$stderr_file"' EXIT - -if ! "$verify_script" <<<"$input" >"$stdout_file" 2>"$stderr_file"; then - result="$(cat "$stdout_file")" - diagnostics="$(cat "$stderr_file")" - emit_report "blocked" \ - "Verification executable exited without a valid workflow report." \ - "Command failed: $verify_script; stdout: ${result:0:1000}; stderr: ${diagnostics:0:1000}" - exit 0 -fi - -result="$(cat "$stdout_file")" -diagnostics="$(cat "$stderr_file")" -if ! jq -e 'type == "object"' <<<"$result" >/dev/null 2>&1; then - emit_report "blocked" \ - "Verification executable returned malformed output." \ - "Invalid JSON from $verify_script: ${result:0:1500}; stderr: ${diagnostics:0:500}" - exit 0 -fi - -raw_status="$(jq -r '.transition // ""' <<<"$result")" -commentary="$(jq -r '.commentary // "Verification completed."' <<<"$result")" -verification_report="$(jq -r '.verification_report // "No report provided."' <<<"$result")" - -case "$raw_status" in - passed) - verification_status="passed" - ;; - blocked) - verification_status="blocked" - ;; - failed) - environment_pattern='SDK location not found|JAVA_HOME|Network is unreachable' - environment_pattern+='|Could not resolve|No space left|Permission denied' - environment_pattern+='|daemon disappeared|Timeout waiting to lock' - environment_pattern+='|could not find executable|Command unavailable' - classification_text="$commentary $verification_report $diagnostics" - if grep -Eqi "$environment_pattern" <<<"$classification_text" || - grep -Eqi "$environment_pattern" "$verify_log" 2>/dev/null; then - verification_status="blocked" - else - verification_status="needs_changes" - fi - ;; - *) - verification_status="blocked" - ;; -esac - -emit_report "$verification_status" "$commentary" "$verification_report" +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.kent/scripts/workflow-wait-github-ci b/.kent/scripts/workflow-wait-github-ci index db0e773f..948dbeb0 100755 --- a/.kent/scripts/workflow-wait-github-ci +++ b/.kent/scripts/workflow-wait-github-ci @@ -2,28 +2,49 @@ from __future__ import annotations import json +import hashlib import os from pathlib import Path +import re +import selectors +import signal import shutil import subprocess import sys import time +import tomllib from typing import Any, Dict, List from urllib.parse import urlparse -SCHEMA_VERSION = 4 - PR_FIELDS = ( "state,mergedAt,mergeCommit,headRefName,headRefOid,baseRefName," "baseRefOid,url" ) CHECK_FIELDS = "bucket,completedAt,link,name,startedAt,state,workflow" DEFAULT_GH_PATHS = ("/opt/homebrew/bin/gh", "/usr/local/bin/gh") +MAX_QUERY_OUTPUT_BYTES = 4 * 1024 * 1024 +MAX_PROFILE_BYTES = 64 * 1024 + + +class QueryError(RuntimeError): + def __init__( + self, + code: str, + *, + exit_code: int | None, + stdout: bytes = b"", + stderr: bytes = b"", + ) -> None: + super().__init__(code) + self.code = code + self.exit_code = exit_code + self.stdout = stdout + self.stderr = stderr -def workflow_result(transition: str, **values: str) -> None: - print(json.dumps({"schema_version": SCHEMA_VERSION, "transition": transition, **values}, ensure_ascii=False)) +def workflow_result(transition: str, **values: object) -> None: + print(json.dumps({"transition": transition, **values}, ensure_ascii=False)) def require_string(payload: Dict[str, Any], key: str) -> str: @@ -33,6 +54,48 @@ def require_string(payload: Dict[str, Any], key: str) -> str: return value.strip() +def _terminate_and_reap( + process: subprocess.Popen[bytes], + *, + process_group_id: int, + deadline: float, +) -> None: + try: + os.killpg(process_group_id, signal.SIGTERM) + except OSError: + pass + remaining = max(0.0, deadline - time.monotonic()) + if process.poll() is None and remaining: + try: + process.wait(timeout=remaining) + except subprocess.TimeoutExpired: + pass + try: + os.killpg(process_group_id, signal.SIGKILL) + except OSError: + pass + if process.returncode is None: + try: + process.kill() + except OSError: + pass + # The recorded group was signaled; wait to reap the direct parent. + if process.returncode is None: + process.wait() + + +def _process_group_exists(process_group_id: int) -> bool: + try: + os.killpg(process_group_id, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return True + return True + + def validate_pr_url(url: str) -> None: parsed = urlparse(url) if parsed.scheme != "https" or parsed.hostname not in { @@ -66,51 +129,188 @@ def run_gh( gh_bin: str, workspace: Path, args: List[str], + *, + timeout: int, ) -> subprocess.CompletedProcess: - return subprocess.run( - [gh_bin] + args, + command = [gh_bin] + args + process = subprocess.Popen( + command, cwd=workspace, - text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - check=False, + start_new_session=True, + ) + process_group_id = os.getpgid(process.pid) + selector = selectors.DefaultSelector() + assert process.stdout is not None + assert process.stderr is not None + selector.register(process.stdout, selectors.EVENT_READ, "stdout") + selector.register(process.stderr, selectors.EVENT_READ, "stderr") + captured = {"stdout": bytearray(), "stderr": bytearray()} + deadline = time.monotonic() + timeout + failure_code: str | None = None + try: + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + failure_code = "github_query_failed" + break + events = selector.select(remaining) + if not events: + failure_code = "github_query_failed" + break + for key, _ in events: + data = os.read(key.fileobj.fileno(), 65536) + if not data: + selector.unregister(key.fileobj) + continue + buffer = captured[key.data] + if len(buffer) < MAX_QUERY_OUTPUT_BYTES + 1: + buffer.extend(data[: MAX_QUERY_OUTPUT_BYTES + 1 - len(buffer)]) + if len(buffer) > MAX_QUERY_OUTPUT_BYTES: + failure_code = "hard_limit" + break + if failure_code is not None: + break + finally: + selector.close() + if failure_code is None and process.poll() is None: + remaining = deadline - time.monotonic() + if remaining > 0: + try: + process.wait(timeout=remaining) + except subprocess.TimeoutExpired: + failure_code = "github_query_failed" + else: + failure_code = "github_query_failed" + if failure_code is not None: + _terminate_and_reap( + process, + process_group_id=process_group_id, + deadline=( + deadline + if failure_code == "github_query_failed" + else time.monotonic() + ), + ) + else: + process.wait(timeout=0) + direct_exit_code = process.returncode + if _process_group_exists(process_group_id): + _terminate_and_reap( + process, + process_group_id=process_group_id, + deadline=time.monotonic(), + ) + if direct_exit_code == 0: + failure_code = "github_query_failed" + stdout = bytes(captured["stdout"]) + stderr = bytes(captured["stderr"]) + try: + process.stdout.close() + finally: + process.stderr.close() + if failure_code == "github_query_failed": + raise QueryError( + "github_query_failed", + exit_code=None, + stdout=stdout, + stderr=stderr, + ) + if failure_code == "hard_limit": + raise QueryError( + "hard_limit", + exit_code=process.returncode, + stdout=stdout, + stderr=stderr, + ) + return subprocess.CompletedProcess( + command, + process.returncode, + stdout, + stderr, ) -def parse_object(result: subprocess.CompletedProcess, label: str) -> Dict[str, Any]: - if result.returncode != 0: - detail = result.stderr.strip() or result.stdout.strip() or "no output" - raise RuntimeError("{} failed: {}".format(label, detail)) +def _decode_json( + result: subprocess.CompletedProcess, + label: str, + *, + fallback: str = "", +) -> object: try: - payload = json.loads(result.stdout) + text = result.stdout.decode("utf-8") + except UnicodeDecodeError as error: + raise QueryError( + "github_output_invalid", + exit_code=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + ) from error + try: + return json.loads(text or fallback) except json.JSONDecodeError as error: - raise RuntimeError("{} returned invalid JSON: {}".format(label, error)) + raise QueryError( + "github_output_invalid", + exit_code=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + ) from error + + +def parse_object(result: subprocess.CompletedProcess, label: str) -> Dict[str, Any]: + if result.returncode != 0: + raise QueryError( + "github_query_failed", + exit_code=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + ) + payload = _decode_json(result, label) if not isinstance(payload, dict): - raise RuntimeError("{} returned a non-object".format(label)) + raise QueryError( + "github_output_invalid", + exit_code=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + ) return payload def parse_array(result: subprocess.CompletedProcess, label: str) -> List[Dict[str, Any]]: if result.returncode not in (0, 1, 8): - detail = result.stderr.strip() or result.stdout.strip() or "no output" - raise RuntimeError("{} failed: {}".format(label, detail)) - try: - payload = json.loads(result.stdout or "[]") - except json.JSONDecodeError as error: - raise RuntimeError("{} returned invalid JSON: {}".format(label, error)) + raise QueryError( + "github_query_failed", + exit_code=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + ) + payload = _decode_json(result, label, fallback="[]") if not isinstance(payload, list) or not all( isinstance(item, dict) for item in payload ): - raise RuntimeError("{} returned a non-array".format(label)) + raise QueryError( + "github_output_invalid", + exit_code=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + ) return payload -def read_pr(gh_bin: str, workspace: Path, pr_url: str) -> Dict[str, Any]: +def read_pr( + gh_bin: str, + workspace: Path, + pr_url: str, + *, + timeout: int, +) -> Dict[str, Any]: return parse_object( run_gh( gh_bin, workspace, ["pr", "view", pr_url, "--json", PR_FIELDS], + timeout=timeout, ), "gh pr view", ) @@ -120,12 +320,15 @@ def read_checks( gh_bin: str, workspace: Path, pr_url: str, + *, + timeout: int, ) -> List[Dict[str, Any]]: return parse_array( run_gh( gh_bin, workspace, ["pr", "checks", pr_url, "--json", CHECK_FIELDS], + timeout=timeout, ), "gh pr checks", ) @@ -144,7 +347,11 @@ def compact_checks(checks: List[Dict[str, Any]]) -> List[Dict[str, Any]]: ] -def prior_attempts(payload: Dict[str, Any]) -> List[Dict[str, Any]]: +def prior_attempts( + payload: Dict[str, Any], + *, + strict: bool = False, +) -> List[Dict[str, Any]]: value = payload.get("ci_report") if value in (None, ""): return [] @@ -156,6 +363,8 @@ def prior_attempts(payload: Dict[str, Any]) -> List[Dict[str, Any]]: raise ValueError("ci_report is not valid JSON: {}".format(error)) if not isinstance(report, dict): raise ValueError("ci_report must decode to one JSON object") + if strict: + runtime_contracts().validate_ci_report(report) attempts = report.get("attempts") if attempts is None: return [{key: item for key, item in report.items() if key != "attempts"}] @@ -223,6 +432,435 @@ def ci_report( ) +def runtime_contracts(): + import importlib.util + + sibling = Path(__file__).with_name("workflow_runtime_contracts.py") + if sibling.is_symlink() or not sibling.is_file(): + raise RuntimeError("runtime contract support module is unsafe") + spec = importlib.util.spec_from_file_location( + "workflow_runtime_contracts", + sibling, + ) + if spec is None or spec.loader is None: + raise RuntimeError("runtime contract support module is unavailable") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def serialize_pr_feedback_cursor(value: object) -> str: + return runtime_contracts().canonical_bytes(value).decode("utf-8") + + +def pr_feedback_cursor_values(payload: Dict[str, Any]) -> Dict[str, Any]: + if "pr_feedback_cursor" not in payload: + return {} + value = payload["pr_feedback_cursor"] + if value == "uninitialized": + return {"pr_feedback_cursor": value} + runtime = runtime_contracts() + if isinstance(value, str): + decoded = runtime.parse_canonical_json( + value, + label="PR feedback cursor", + max_bytes=runtime.MAX_FEEDBACK_BYTES, + ) + if not isinstance(decoded, dict): + raise ValueError("PR feedback cursor must encode an object") + value = decoded + validated = runtime.validate_pr_feedback_cursor(value) + return { + "pr_feedback_cursor": serialize_pr_feedback_cursor(validated) + } + + +def profile_schema3_legacy(workspace: Path) -> bool: + profile = workspace / ".kent" / "workflow-profile.toml" + try: + raw = profile.read_bytes() + except OSError: + return False + if len(raw) > MAX_PROFILE_BYTES: + return False + try: + value = tomllib.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError): + return False + schema = value.get("schema_version") if isinstance(value, dict) else None + return type(schema) is int and schema == 3 + + +def _safe_error( + error: Exception | None, + *, + default_code: str = "expected_contract_invalid", +) -> dict[str, Any]: + if isinstance(error, QueryError): + return { + "code": error.code, + "exit_code": error.exit_code, + "stdout_sha256": hashlib.sha256(error.stdout).hexdigest(), + "stderr_sha256": hashlib.sha256(error.stderr).hexdigest(), + } + return { + "code": default_code, + "exit_code": None, + "stdout_sha256": hashlib.sha256(b"").hexdigest(), + "stderr_sha256": hashlib.sha256(b"").hexdigest(), + } + + +def v2_ci_observation(checks: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return [ + { + "workflow_name": str(item.get("workflow") or ""), + "check_name": str(item.get("name") or ""), + "bucket": str(item.get("bucket") or "").lower(), + "state": str(item.get("state") or "").upper(), + "link": item.get("link"), + } + for item in checks + ] + + +def v2_safe_report( + payload: Dict[str, Any], + *, + reason: str, + pr: Dict[str, Any] | None = None, + error: Exception | None = None, + watch_exit: int | None = None, +) -> str: + runtime = runtime_contracts() + parsed_url = urlparse(str(payload["pr_url"])) + parts = [part for part in parsed_url.path.split("/") if part] + repository = str(payload.get("repository") or "") + if not repository and len(parts) >= 4: + repository = "{}/{}".format(parts[0], parts[1]) + if not repository or "/" not in repository: + repository = "unknown/unknown" + envelope = payload.get("runtime_source_envelope_digest") + if not isinstance(envelope, str) or not re.fullmatch(r"[0-9a-f]{64}", envelope): + envelope = "0" * 64 + expected_digest = payload.get("expected_ci_checks_sha256") + if not isinstance(expected_digest, str) or not re.fullmatch( + r"[0-9a-f]{64}", + expected_digest, + ): + expected_digest = "0" * 64 + head = str((pr or {}).get("headRefOid") or "") + base = str((pr or {}).get("baseRefOid") or "") + if not re.fullmatch(r"[0-9a-f]{40}", head): + head = "0" * 40 + if not re.fullmatch(r"[0-9a-f]{40}", base): + base = "0" * 40 + previous = None + raw_previous = payload.get("ci_report") + if isinstance(raw_previous, str) and raw_previous: + try: + previous = runtime.validate_ci_report(json.loads(raw_previous)) + except (ValueError, json.JSONDecodeError): + previous = None + sequence = ( + previous["attempts"][-1]["sequence"] + 1 + if previous is not None + else 1 + ) + attempt = { + "sequence": sequence, + "head_oid": head, + "base_oid": base, + "reason": reason, + "watcher_exit_code": watch_exit, + "expected_checks": [], + "unexpected_check_count": 0, + "unexpected_checks_sha256": runtime.canonical_sha256([]), + "retry": None, + "safe_error": _safe_error(error, default_code=reason), + } + projected_receipt = runtime.RejectedObservationReceipt( + "projected_rows", + 0, + runtime.canonical_sha256([]), + ) + attempt = runtime.prepare_ci_attempt(attempt, projected_receipt) + if previous is None: + report = runtime.build_ci_report( + mode="expected-v1", + repository=repository, + pull_number=int(parts[-1]) if parts and parts[-1].isdigit() else 1, + runtime_source_envelope_digest=envelope, + expected_ci_checks_sha256=expected_digest, + attempts=[attempt], + ) + else: + report = runtime.append_ci_report_attempt(previous, attempt) + return json.dumps( + report, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + + +def _v2_report_from_attempt( + runtime: Any, + payload: Dict[str, Any], + repository: str, + expected_digest: str, + previous: Dict[str, Any] | None, + attempt: Dict[str, Any], + projected_receipt: Any | None = None, + *, + prepare: bool = True, +) -> str: + if prepare: + if projected_receipt is None: + raise RuntimeError("ordinary CI attempts require a projected receipt") + attempt = runtime.prepare_ci_attempt(attempt, projected_receipt) + if previous is None: + report = runtime.build_ci_report( + mode="expected-v1", + repository=repository, + pull_number=int(str(payload["pr_url"]).rstrip("/").split("/")[-1]), + runtime_source_envelope_digest=payload["runtime_source_envelope_digest"], + expected_ci_checks_sha256=expected_digest, + attempts=[attempt], + ) + else: + report = runtime.append_ci_report_attempt(previous, attempt) + return json.dumps( + report, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + + +def v2_ci_report( + payload: Dict[str, Any], + pr: Dict[str, Any], + checks: List[Dict[str, Any]], + *, + watch_exit: int, +) -> tuple[str, str | None, str]: + runtime = runtime_contracts() + raw_expected = payload.get("expected_ci_checks") + if isinstance(raw_expected, str): + expected = runtime.parse_canonical_json( + raw_expected, + label="expected_ci_checks", + max_bytes=runtime.MAX_EXPECTED_CHECKS_BYTES, + ) + else: + expected = raw_expected + if isinstance(expected, dict): + expected = runtime.validate_expected_ci_checks(expected) + envelope = payload.get("runtime_source_envelope_digest") + if ( + not isinstance(expected, dict) + or not isinstance(envelope, str) + or not re.fullmatch(r"[0-9a-f]{64}", envelope) + ): + raise RuntimeError( + "v2 CI requires expected_ci_checks and runtime_source_envelope_digest" + ) + expected_digest = payload.get("expected_ci_checks_sha256") + if ( + not isinstance(expected_digest, str) + or expected_digest != runtime.expected_ci_checks_sha256(expected) + ): + raise RuntimeError("expected_ci_checks_sha256 is missing or invalid") + repository = str(payload.get("repository") or "") + if not repository: + parsed_url = urlparse(str(payload["pr_url"])) + parts = [part for part in parsed_url.path.split("/") if part] + if len(parts) < 4: + raise RuntimeError("cannot derive GitHub repository from pr_url") + repository = "{}/{}".format(parts[0], parts[1]) + observations = v2_ci_observation(checks) + classification = runtime.classify_expected_ci_checks_with_receipt( + expected, + observations, + current_repository=repository, + current_head_oid=str(pr.get("headRefOid") or ""), + runtime_source_envelope_digest=envelope, + expected_checks_digest=expected_digest, + ) + previous = None + previous_raw = payload.get("ci_report") + if isinstance(previous_raw, str) and previous_raw: + previous = runtime.validate_ci_report(json.loads(previous_raw)) + sequence = ( + previous["attempts"][-1]["sequence"] + 1 + if previous is not None + else 1 + ) + if classification.state == "grammar_invalid": + receipt = classification.projected_observations + if not isinstance(receipt, runtime.RejectedObservationReceipt): + raise RuntimeError("grammar-invalid observations have no receipt") + attempt = runtime.make_observation_limit_attempt( + sequence=sequence, + head_oid=str(pr.get("headRefOid") or ""), + base_oid=str(pr.get("baseRefOid") or ""), + receipt=receipt, + watcher_exit_code=watch_exit, + ) + return ( + "ci_watch_failed", + _v2_report_from_attempt( + runtime, + payload, + repository, + expected_digest, + previous, + attempt, + prepare=False, + ), + "report_invalid", + ) + if classification.state == "hard_limit": + hard_limit = ( + classification.projected_observations + if isinstance( + classification.projected_observations, + runtime.RejectedObservationHardLimit, + ) + else classification.unexpected_observations + ) + if not isinstance(hard_limit, runtime.RejectedObservationHardLimit): + raise RuntimeError("hard-limit observations have no receipt") + attempt = runtime.make_observation_hard_limit_attempt( + sequence=sequence, + head_oid=str(pr.get("headRefOid") or ""), + base_oid=str(pr.get("baseRefOid") or ""), + hard_limit=hard_limit, + watcher_exit_code=watch_exit, + ) + return ( + "ci_watch_failed", + _v2_report_from_attempt( + runtime, + payload, + repository, + expected_digest, + previous, + attempt, + prepare=False, + ), + "report_invalid", + ) + if classification.state == "observation_limit": + receipt = ( + classification.unexpected_observations + if classification.unexpected_observations is not None + else classification.projected_observations + ) + if not isinstance(receipt, runtime.RejectedObservationReceipt): + raise RuntimeError("observation-limit observations have no receipt") + attempt = runtime.make_observation_limit_attempt( + sequence=sequence, + head_oid=str(pr.get("headRefOid") or ""), + base_oid=str(pr.get("baseRefOid") or ""), + receipt=receipt, + watcher_exit_code=watch_exit, + ) + return ( + "ci_watch_failed", + _v2_report_from_attempt( + runtime, + payload, + repository, + expected_digest, + previous, + attempt, + prepare=False, + ), + "report_invalid", + ) + classification_value = classification.materialize_value() + projected_receipt = classification.projected_observations + if not isinstance(projected_receipt, runtime.RejectedObservationReceipt): + projected_receipt = runtime.RejectedObservationReceipt( + "projected_rows", + 0, + runtime.canonical_sha256([]), + ) + reason = str(classification_value["transition"]) + if reason == "source_changed": + return "ci_watch_source_changed", None, reason + if reason not in runtime.CI_REPORT_REASONS: + reason = "report_invalid" + attempt = { + "sequence": sequence, + "head_oid": str(pr.get("headRefOid") or ""), + "base_oid": str(pr.get("baseRefOid") or ""), + "reason": reason, + "watcher_exit_code": watch_exit, + "expected_checks": classification_value.get("expected_checks", []), + "unexpected_check_count": classification_value.get( + "unexpected_check_count", + 0, + ), + "unexpected_checks_sha256": classification_value.get( + "unexpected_checks_sha256", + runtime.canonical_sha256([]), + ), + "retry": None, + "safe_error": None, + } + report = _v2_report_from_attempt( + runtime, + payload, + repository, + expected_digest, + previous, + attempt, + projected_receipt, + ) + if reason == "all_expected_checks_terminal_green": + transition = "ci_watch_passed" + elif reason in {"expected_check_missing", "pending_limit", "no_checks_reported"}: + transition = "waiting" + else: + transition = "ci_watch_failed" + return transition, report, reason + + +def v2_pending_limit_report( + report_json: str, + pr: Dict[str, Any], + *, + watch_exit: int, +) -> str: + runtime = runtime_contracts() + previous = runtime.validate_ci_report(json.loads(report_json)) + latest = previous["attempts"][-1] + attempt = { + **latest, + "sequence": latest["sequence"] + 1, + "head_oid": str(pr.get("headRefOid") or ""), + "base_oid": str(pr.get("baseRefOid") or ""), + "reason": "pending_limit", + "watcher_exit_code": watch_exit, + "safe_error": None, + } + projected_receipt = runtime.RejectedObservationReceipt( + "projected_rows", + 0, + runtime.canonical_sha256([]), + ) + attempt = runtime.prepare_ci_attempt(attempt, projected_receipt) + return json.dumps( + runtime.append_ci_report_attempt(previous, attempt), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + + def main() -> int: try: payload = json.load(sys.stdin) @@ -237,7 +875,50 @@ def main() -> int: pr_url = require_string(payload, "pr_url") branch_name = require_string(payload, "branch_name") merge_strategy = require_string(payload, "merge_strategy") - previous_attempts = prior_attempts(payload) + v2_mode = not ( + profile_schema3_legacy(workspace) and "ci_report" not in payload + ) + runtime = runtime_contracts() if v2_mode else None + prevalidation_error: Exception | None = None + cursor: Dict[str, Any] = {} + if v2_mode: + try: + cursor = pr_feedback_cursor_values(payload) + except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as error: + prevalidation_error = error + if v2_mode: + try: + raw_expected = payload.get("expected_ci_checks") + if not isinstance(raw_expected, str): + raise ValueError("expected_ci_checks must be canonical JSON") + expected = runtime.parse_canonical_json( + raw_expected, + label="expected_ci_checks", + max_bytes=runtime.MAX_EXPECTED_CHECKS_BYTES, + ) + expected = runtime.validate_expected_ci_checks(expected) + supplied_digest = payload.get("expected_ci_checks_sha256") + if ( + not isinstance(supplied_digest, str) + or supplied_digest != runtime.expected_ci_checks_sha256(expected) + ): + raise ValueError("expected_ci_checks_sha256 is missing or invalid") + envelope = payload.get("runtime_source_envelope_digest") + if not isinstance(envelope, str) or not re.fullmatch( + r"[0-9a-f]{64}", + envelope, + ): + raise ValueError("runtime_source_envelope_digest is invalid") + if "ci_report" in payload: + raw_report = payload["ci_report"] + if not isinstance(raw_report, str) or not raw_report: + raise ValueError("ci_report is invalid") + runtime.validate_ci_report(json.loads(raw_report)) + except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as error: + if prevalidation_error is None: + prevalidation_error = error + else: + previous_attempts = prior_attempts(payload) validate_pr_url(pr_url) if not workspace.is_dir(): raise ValueError("workspace does not exist: {}".format(workspace)) @@ -250,15 +931,45 @@ def main() -> int: test_mode = os.environ.get("KENT_CI_WATCH_TEST_MODE") == "1" interval = int(os.environ.get("KENT_CI_WATCH_INTERVAL_SECONDS", "30")) interval = max(0 if test_mode else 30, interval) - max_polls = int(os.environ.get("KENT_CI_WATCH_MAX_POLLS", "0")) + query_timeout = max( + 1, + int(os.environ.get("KENT_CI_WATCH_QUERY_TIMEOUT_SECONDS", "60")), + ) + max_polls = int(os.environ.get("KENT_CI_WATCH_MAX_POLLS", "120")) max_errors = max(1, int(os.environ.get("KENT_CI_WATCH_MAX_ERRORS", "5"))) errors = 0 polls = 0 + if v2_mode and prevalidation_error is not None: + reason = ( + "report_invalid" + if isinstance(payload.get("ci_report"), str) + and payload.get("ci_report") + else "expected_contract_invalid" + ) + workflow_result( + "ci_watch_failed", + workspace_path=str(workspace), + pr_url=pr_url, + branch_name=branch_name, + merge_strategy=merge_strategy, + ci_report=v2_safe_report( + payload, + reason=reason, + error=prevalidation_error, + ), + **cursor, + ) + return 0 while True: polls += 1 try: - pr = read_pr(gh_bin, workspace, pr_url) + pr = read_pr( + gh_bin, + workspace, + pr_url, + timeout=query_timeout, + ) if str(pr.get("state") or "").upper() == "MERGED": workflow_result( "ci_watch_pr_merged", @@ -280,12 +991,40 @@ def main() -> int: "--interval", str(max(1, interval)), ], + timeout=query_timeout, + ) + if watch.returncode not in (0, 1, 8): + raise QueryError( + "github_query_failed", + exit_code=watch.returncode, + stdout=watch.stdout, + stderr=watch.stderr, + ) + checks = read_checks( + gh_bin, + workspace, + pr_url, + timeout=query_timeout, ) - checks = read_checks(gh_bin, workspace, pr_url) errors = 0 - except RuntimeError as error: + except (QueryError, RuntimeError, ValueError) as error: errors += 1 if errors >= max_errors: + if v2_mode: + workflow_result( + "ci_watch_failed", + workspace_path=str(workspace), + pr_url=pr_url, + branch_name=branch_name, + merge_strategy=merge_strategy, + ci_report=v2_safe_report( + payload, + reason="github_query_failed", + error=error, + ), + **cursor, + ) + return 0 workflow_result( "ci_watch_failed", workspace_path=str(workspace), @@ -296,10 +1035,11 @@ def main() -> int: { "reason": "github_query_failed", "query_attempts": errors, - "error": str(error), + "safe_error": _safe_error(error), }, previous_attempts, ), + **cursor, ) return 0 print( @@ -313,6 +1053,67 @@ def main() -> int: time.sleep(interval) continue + if v2_mode: + try: + transition, report, reason = v2_ci_report( + payload, + pr, + checks, + watch_exit=watch.returncode, + ) + except ( + OSError, + RuntimeError, + ValueError, + json.JSONDecodeError, + ) as error: + transition = "ci_watch_failed" + reason = ( + "report_invalid" + if isinstance(payload.get("ci_report"), str) + and payload.get("ci_report") + else "expected_contract_invalid" + ) + report = v2_safe_report( + payload, + reason=reason, + pr=pr, + error=error, + watch_exit=watch.returncode, + ) + reason = "report_invalid" + if transition == "waiting": + payload["ci_report"] = report + if max_polls and polls >= max_polls: + if reason != "pending_limit": + report = v2_pending_limit_report( + report, + pr, + watch_exit=watch.returncode, + ) + workflow_result( + "ci_watch_failed", + workspace_path=str(workspace), + pr_url=pr_url, + branch_name=branch_name, + merge_strategy=merge_strategy, + ci_report=report, + **cursor, + ) + return 0 + time.sleep(interval) + continue + workflow_result( + transition, + workspace_path=str(workspace), + pr_url=pr_url, + branch_name=branch_name, + merge_strategy=merge_strategy, + **({"ci_report": report} if report is not None else {}), + **cursor, + ) + return 0 + buckets = { str(check.get("bucket") or "").lower() for check in checks } @@ -330,6 +1131,7 @@ def main() -> int: watch.returncode, previous_attempts, ), + **cursor, ) return 0 if buckets & {"fail", "cancel"}: @@ -346,6 +1148,7 @@ def main() -> int: watch.returncode, previous_attempts, ), + **cursor, ) return 0 if "pending" not in buckets and buckets <= {"pass", "skipping"}: @@ -362,6 +1165,7 @@ def main() -> int: watch.returncode, previous_attempts, ), + **cursor, ) return 0 @@ -379,6 +1183,7 @@ def main() -> int: watch.returncode, previous_attempts, ), + **cursor, ) return 0 time.sleep(interval) @@ -387,6 +1192,6 @@ def main() -> int: if __name__ == "__main__": try: raise SystemExit(main()) - except (OSError, ValueError) as error: + except (OSError, RuntimeError, ValueError) as error: print("workflow-wait-github-ci: {}".format(error), file=sys.stderr) raise SystemExit(1) diff --git a/.kent/scripts/workflow-wait-github-pr b/.kent/scripts/workflow-wait-github-pr index ab7db42f..99bce4b3 100755 --- a/.kent/scripts/workflow-wait-github-pr +++ b/.kent/scripts/workflow-wait-github-pr @@ -2,16 +2,39 @@ from __future__ import annotations import json +import hashlib import os from pathlib import Path +import selectors +import signal import shutil import subprocess import sys import time +import tomllib from urllib.parse import urlparse -SCHEMA_VERSION = 4 +MAX_QUERY_OUTPUT_BYTES = 4 * 1024 * 1024 +MAX_FEEDBACK_BODY_BYTES = 64 * 1024 +MAX_PROFILE_BYTES = 64 * 1024 + + +class QueryError(RuntimeError): + def __init__( + self, + code: str, + *, + exit_code: int | None, + stdout: bytes = b"", + stderr: bytes = b"", + ) -> None: + super().__init__(code) + self.code = code + self.exit_code = exit_code + self.stdout = stdout + self.stderr = stderr + FIELDS = ( "state,mergedAt,mergeCommit,headRefName,headRefOid,baseRefName,baseRefOid," @@ -32,8 +55,8 @@ DEFAULT_GH_PATHS = ( ) -def workflow_result(transition: str, **values: str) -> None: - print(json.dumps({"schema_version": SCHEMA_VERSION, "transition": transition, **values}, ensure_ascii=False)) +def workflow_result(transition: str, **values: object) -> None: + print(json.dumps({"transition": transition, **values}, ensure_ascii=False)) def require_string(payload: dict[str, object], key: str) -> str: @@ -43,6 +66,48 @@ def require_string(payload: dict[str, object], key: str) -> str: return value.strip() +def _terminate_and_reap( + process: subprocess.Popen[bytes], + *, + process_group_id: int, + deadline: float, +) -> None: + try: + os.killpg(process_group_id, signal.SIGTERM) + except OSError: + pass + remaining = max(0.0, deadline - time.monotonic()) + if process.poll() is None and remaining: + try: + process.wait(timeout=remaining) + except subprocess.TimeoutExpired: + pass + try: + os.killpg(process_group_id, signal.SIGKILL) + except OSError: + pass + if process.returncode is None: + try: + process.kill() + except OSError: + pass + # The recorded group was signaled; wait to reap the direct parent. + if process.returncode is None: + process.wait() + + +def _process_group_exists(process_group_id: int) -> bool: + try: + os.killpg(process_group_id, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return True + return True + + def validate_pr_url(url: str) -> None: parsed = urlparse(url) if parsed.scheme != "https" or parsed.hostname not in {"github.com", "www.github.com"}: @@ -52,6 +117,780 @@ def validate_pr_url(url: str) -> None: raise ValueError("pr_url must identify one GitHub pull request") +def profile_schema3_legacy(workspace: Path) -> bool: + profile = workspace / ".kent" / "workflow-profile.toml" + try: + raw = profile.read_bytes() + except OSError: + return False + if len(raw) > MAX_PROFILE_BYTES: + return False + try: + value = tomllib.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError): + return False + schema = value.get("schema_version") if isinstance(value, dict) else None + return type(schema) is int and schema == 3 + + +def runtime_contracts(): + import importlib.util + + sibling = Path(__file__).with_name("workflow_runtime_contracts.py") + if sibling.is_symlink() or not sibling.is_file(): + raise ValueError("runtime contract support module is unsafe") + spec = importlib.util.spec_from_file_location( + "workflow_runtime_contracts", + sibling, + ) + if spec is None or spec.loader is None: + raise ValueError("runtime contract support module is unavailable") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def feedback_cursor(payload: dict[str, object]) -> object: + value = payload.get("pr_feedback_cursor") + if value is None: + return None + if value == "uninitialized": + return value + runtime = runtime_contracts() + if isinstance(value, str): + decoded = runtime.parse_canonical_json( + value, + label="PR feedback cursor", + max_bytes=runtime.MAX_FEEDBACK_BYTES, + ) + if not isinstance(decoded, dict): + raise ValueError("PR feedback cursor must encode an object") + value = decoded + return runtime.validate_pr_feedback_cursor(value) + + +def serialize_cursor(value: object) -> str: + return runtime_contracts().canonical_bytes(value).decode("utf-8") + + +def cursor_values(payload: dict[str, object]) -> dict[str, object]: + value = feedback_cursor(payload) + if value is None: + return {} + if value == "uninitialized": + return {"pr_feedback_cursor": value} + return { + "pr_feedback_cursor": serialize_cursor(value) + } + + +def materialize_cursor( + payload: dict[str, object], + state: dict[str, object], + pr_url: str, +) -> object: + previous = feedback_cursor(payload) + if previous is None: + return None + if "feedback_items" not in state and "feedback_checks" not in state: + return previous + parsed = urlparse(pr_url) + parts = [part for part in parsed.path.split("/") if part] + if parsed.hostname not in {"github.com", "www.github.com"} or len(parts) != 4: + raise ValueError("cannot derive GitHub repository from pr_url") + checks = state.get("feedback_checks", []) + if not isinstance(checks, list): + raise ValueError("feedback_checks must be an array") + items = state.get("feedback_items", []) + if not isinstance(items, list): + raise ValueError("feedback_items must be an array") + return runtime_contracts().make_pr_feedback_cursor( + repository="{}/{}".format(parts[0], parts[1]), + pull_number=int(parts[3]), + head_oid=str(state.get("headRefOid") or ""), + base_oid=str(state.get("baseRefOid") or ""), + pr_state=str(state.get("state") or "").upper(), + review_decision=str(state.get("reviewDecision") or "").upper(), + merge_state_status=str(state.get("mergeStateStatus") or "").upper(), + checks=checks, + items=items, + ) + + +def _body_digest(value: object) -> tuple[int, str]: + if value is not None and not isinstance(value, str): + raise ValueError("GitHub feedback item body must be text or null") + body = "" if value is None else value + encoded = body.encode("utf-8") + if len(encoded) > MAX_FEEDBACK_BODY_BYTES: + raise ValueError("feedback_body_hard_limit") + return len(encoded), hashlib.sha256(encoded).hexdigest() + + +def _timestamp(value: object, fallback: str = "") -> str: + candidate = str(value or fallback) + if not candidate: + raise ValueError("GitHub feedback item has no timestamp") + return candidate + + +def _feedback_item(kind: str, raw: dict[str, object]) -> dict[str, object]: + body_bytes, body_sha256 = _body_digest(raw.get("body")) + author = raw.get("author") + if isinstance(author, dict): + author_login = author.get("login") + else: + author_login = raw.get("user", {}).get("login") if isinstance( + raw.get("user"), dict + ) else raw.get("author_login") + if author_login is not None: + if not isinstance(author_login, str): + raise ValueError("GitHub feedback item author is malformed") + author_login = author_login.strip() + if not author_login: + author_login = None + identifier = raw.get("id") or raw.get("node_id") + if not isinstance(identifier, (str, int)) or not str(identifier).strip(): + raise ValueError("GitHub feedback item has no stable ID") + if kind == "issue_comment": + return { + "kind": kind, + "id": str(identifier), + "author_login": author_login, + "created_at": _timestamp( + raw.get("created_at") or raw.get("createdAt"), + "", + ), + "updated_at": _timestamp( + raw.get("updated_at") or raw.get("updatedAt"), + "", + ), + "body_bytes": body_bytes, + "body_sha256": body_sha256, + } + if kind == "review": + commit = raw.get("commit_id") or raw.get("commitId") + return { + "kind": kind, + "id": str(identifier), + "author_login": author_login, + "state": str(raw.get("state") or "").upper(), + "submitted_at": ( + None + if raw.get("submitted_at", raw.get("submittedAt")) is None + else _timestamp( + raw.get("submitted_at") or raw.get("submittedAt") + ) + ), + "updated_at": _timestamp( + raw.get("updated_at") + or raw.get("updatedAt") + or raw.get("submitted_at") + or raw.get("submittedAt"), + "", + ), + "commit_oid": None if commit is None else str(commit), + "body_bytes": body_bytes, + "body_sha256": body_sha256, + } + if kind == "review_comment": + current = raw.get("commit_id") or raw.get("current_commit_oid") + original = raw.get("original_commit_id") or raw.get( + "original_commit_oid" + ) + return { + "kind": kind, + "id": str(identifier), + "thread_id": str( + raw.get("pull_request_review_thread_id") + or raw.get("thread_id") + or raw.get("in_reply_to_id") + or raw.get("id") + or "" + ), + "author_login": author_login, + "created_at": _timestamp( + raw.get("created_at") or raw.get("createdAt"), + "", + ), + "updated_at": _timestamp( + raw.get("updated_at") + or raw.get("updatedAt") + or raw.get("created_at") + or raw.get("createdAt"), + "", + ), + "current_commit_oid": None if current is None else str(current), + "original_commit_oid": None if original is None else str(original), + "body_bytes": body_bytes, + "body_sha256": body_sha256, + } + raise ValueError("unsupported feedback kind") + + +def _query_bytes(value: object) -> bytes: + return value if isinstance(value, bytes) else b"" + + +def _run_query( + command: list[str], + workspace: Path, + *, + timeout: int, +) -> tuple[bytes, bytes, int]: + process = subprocess.Popen( + command, + cwd=workspace, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + process_group_id = os.getpgid(process.pid) + assert process.stdout is not None + assert process.stderr is not None + selector = selectors.DefaultSelector() + selector.register(process.stdout, selectors.EVENT_READ, "stdout") + selector.register(process.stderr, selectors.EVENT_READ, "stderr") + captured = {"stdout": bytearray(), "stderr": bytearray()} + deadline = time.monotonic() + timeout + failure_code: str | None = None + try: + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + failure_code = "github_query_failed" + break + events = selector.select(remaining) + if not events: + failure_code = "github_query_failed" + break + for key, _ in events: + data = os.read(key.fileobj.fileno(), 65536) + if not data: + selector.unregister(key.fileobj) + continue + buffer = captured[key.data] + if len(buffer) < MAX_QUERY_OUTPUT_BYTES + 1: + buffer.extend( + data[: MAX_QUERY_OUTPUT_BYTES + 1 - len(buffer)] + ) + if len(buffer) > MAX_QUERY_OUTPUT_BYTES: + failure_code = "hard_limit" + break + if failure_code is not None: + break + finally: + selector.close() + if failure_code is None and process.poll() is None: + remaining = deadline - time.monotonic() + if remaining > 0: + try: + process.wait(timeout=remaining) + except subprocess.TimeoutExpired: + failure_code = "github_query_failed" + else: + failure_code = "github_query_failed" + if failure_code is not None: + _terminate_and_reap( + process, + process_group_id=process_group_id, + deadline=( + deadline + if failure_code == "github_query_failed" + else time.monotonic() + ), + ) + else: + process.wait(timeout=0) + direct_exit_code = process.returncode + if _process_group_exists(process_group_id): + _terminate_and_reap( + process, + process_group_id=process_group_id, + deadline=time.monotonic(), + ) + if direct_exit_code == 0: + failure_code = "github_query_failed" + stdout = bytes(captured["stdout"]) + stderr = bytes(captured["stderr"]) + try: + process.stdout.close() + finally: + process.stderr.close() + if failure_code == "github_query_failed": + raise QueryError( + "github_query_failed", + exit_code=None, + stdout=stdout, + stderr=stderr, + ) + if failure_code == "hard_limit": + raise QueryError( + "hard_limit", + exit_code=process.returncode, + stdout=stdout, + stderr=stderr, + ) + if process.returncode != 0: + raise QueryError( + "github_query_failed", + exit_code=process.returncode, + stdout=stdout, + stderr=stderr, + ) + return stdout, stderr, process.returncode + + +def _json_output( + stdout: bytes, + *, + label: str, + fallback: str = "", + stderr: bytes = b"", +) -> object: + try: + text = stdout.decode("utf-8") + except UnicodeDecodeError as error: + raise QueryError( + "github_output_invalid", + exit_code=0, + stdout=stdout, + stderr=stderr, + ) from error + try: + return json.loads(text or fallback) + except json.JSONDecodeError as error: + raise QueryError( + "github_output_invalid", + exit_code=0, + stdout=stdout, + stderr=stderr, + ) from error + + +def _paginate_feedback( + gh_bin: str, + workspace: Path, + endpoint: str, + *, + timeout: int, +) -> list[dict[str, object]]: + stdout, stderr, _exit_code = _run_query( + [ + gh_bin, + "api", + "--paginate", + "--slurp", + endpoint, + ], + workspace, + timeout=timeout, + ) + value = _json_output( + stdout, + label="GitHub feedback query", + fallback="[]", + stderr=stderr, + ) + if not isinstance(value, list): + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) + if value and all(isinstance(page, list) for page in value): + pages = value + else: + pages = [value] + flattened: list[dict[str, object]] = [] + for page in pages: + if not isinstance(page, list): + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) + if len(flattened) + len(page) > 1000: + raise ValueError("feedback_hard_limit") + for item in page: + if not isinstance(item, dict): + raise QueryError( + "github_output_invalid", + exit_code=0, + stdout=stdout, + ) + flattened.append(item) + return flattened + + +def _read_review_threads( + gh_bin: str, + workspace: Path, + repository: str, + number: str, + *, + timeout: int, +) -> list[dict[str, object]]: + query = """ +query($owner:String!, $name:String!, $number:Int!, $endCursor:String) { + repository(owner:$owner, name:$name) { + pullRequest(number:$number) { + reviewThreads(first:100, after:$endCursor) { + nodes { + id + isResolved + isOutdated + path + line + startLine + originalLine + originalStartLine + subjectType + comments(first:100) { + nodes { id } + pageInfo { hasNextPage endCursor } + } + } + pageInfo { hasNextPage endCursor } + } + } + } +} +""" + owner, name = repository.split("/", 1) + stdout, stderr, _exit_code = _run_query( + [ + gh_bin, + "api", + "graphql", + "--paginate", + "--slurp", + "-f", + "query=" + query, + "-F", + "owner=" + owner, + "-F", + "name=" + name, + "-F", + "number=" + number, + "-F", + "endCursor=null", + ], + workspace, + timeout=timeout, + ) + payload = _json_output( + stdout, + label="GitHub review-thread query", + fallback="[]", + stderr=stderr, + ) + if not isinstance(payload, list): + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) + pages = payload + threads: list[dict[str, object]] = [] + observation_count = 0 + for page in pages: + if not isinstance(page, dict): + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) + data = page.get("data") + if not isinstance(data, dict): + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) + repository_data = data.get("repository") + if not isinstance(repository_data, dict): + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) + pull = repository_data.get("pullRequest") + if not isinstance(pull, dict): + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) + connection = pull.get("reviewThreads") + if not isinstance(connection, dict): + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) + nodes = connection.get("nodes") + page_info = connection.get("pageInfo") + if not isinstance(nodes, list) or not isinstance(page_info, dict): + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) + if not isinstance(page_info.get("hasNextPage"), bool): + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) + end_cursor = page_info.get("endCursor") + if end_cursor is not None and not isinstance(end_cursor, str): + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) + if page_info["hasNextPage"] and not end_cursor: + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) + for node in nodes: + if not isinstance(node, dict): + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) + for required in ("id", "path", "subjectType"): + if not isinstance(node.get(required), str) or not node[required]: + raise QueryError( + "github_output_invalid", + exit_code=0, + stdout=stdout, + ) + comments = node.get("comments") + if not isinstance(comments, dict): + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) + comment_nodes = comments.get("nodes") + comment_page_info = comments.get("pageInfo") + if not isinstance(comment_nodes, list) or not isinstance( + comment_page_info, + dict, + ): + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) + if not isinstance(comment_page_info.get("hasNextPage"), bool): + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) + comment_ids: list[str] = [] + for item in comment_nodes: + if ( + not isinstance(item, dict) + or not isinstance(item.get("id"), str) + or not item["id"] + ): + raise QueryError( + "github_output_invalid", + exit_code=0, + stdout=stdout, + ) + comment_ids.append(item["id"]) + observation_count += 1 + len(comment_ids) + if observation_count > 1000: + raise ValueError("feedback_hard_limit") + if comment_page_info["hasNextPage"]: + cursor = comment_page_info.get("endCursor") + if not isinstance(cursor, str) or not cursor: + raise QueryError( + "github_output_invalid", + exit_code=0, + stdout=stdout, + ) + continuation = """ +query($threadId:ID!, $endCursor:String) { + node(id:$threadId) { + ... on PullRequestReviewThread { + comments(first:100, after:$endCursor) { + nodes { id } + pageInfo { hasNextPage endCursor } + } + } + } +} +""" + while True: + continuation_stdout, continuation_stderr, _exit_code = _run_query( + [ + gh_bin, + "api", + "graphql", + "-f", + "query=" + continuation, + "-F", + "threadId=" + node["id"], + "-F", + "endCursor=" + cursor, + ], + workspace, + timeout=timeout, + ) + continuation_value = _json_output( + continuation_stdout, + label="GitHub review-thread continuation", + stderr=continuation_stderr, + ) + if not isinstance(continuation_value, dict): + raise QueryError( + "github_output_invalid", + exit_code=0, + stdout=continuation_stdout, + ) + continuation_data = continuation_value.get("data") + continuation_node = ( + continuation_data.get("node") + if isinstance(continuation_data, dict) + else None + ) + continuation_comments = ( + continuation_node.get("comments") + if isinstance(continuation_node, dict) + else None + ) + if not isinstance(continuation_comments, dict): + raise QueryError( + "github_output_invalid", + exit_code=0, + stdout=continuation_stdout, + ) + continuation_nodes = continuation_comments.get("nodes") + continuation_info = continuation_comments.get("pageInfo") + if not isinstance(continuation_nodes, list) or not isinstance( + continuation_info, + dict, + ): + raise QueryError( + "github_output_invalid", + exit_code=0, + stdout=continuation_stdout, + ) + for item in continuation_nodes: + if ( + not isinstance(item, dict) + or not isinstance(item.get("id"), str) + or not item["id"] + ): + raise QueryError( + "github_output_invalid", + exit_code=0, + stdout=continuation_stdout, + ) + comment_ids.append(item["id"]) + observation_count += 1 + if observation_count > 1000: + raise ValueError("feedback_hard_limit") + if not isinstance( + continuation_info.get("hasNextPage"), + bool, + ): + raise QueryError( + "github_output_invalid", + exit_code=0, + stdout=continuation_stdout, + ) + if not continuation_info["hasNextPage"]: + break + cursor = continuation_info.get("endCursor") + if not isinstance(cursor, str) or not cursor: + raise QueryError( + "github_output_invalid", + exit_code=0, + stdout=continuation_stdout, + ) + threads.append( + { + "kind": "review_thread", + "id": node["id"], + "resolved": node.get("isResolved"), + "outdated": node.get("isOutdated"), + "path": node["path"], + "current_line": node.get("line"), + "current_start_line": node.get("startLine"), + "original_line": node.get("originalLine"), + "original_start_line": node.get("originalStartLine"), + "subject_type": node["subjectType"], + "comment_ids": comment_ids, + } + ) + if len(threads) > 1000: + raise ValueError("feedback_hard_limit") + return threads + + +def read_feedback( + workspace: Path, + pr_url: str, + gh_bin: str, + *, + timeout: int, +) -> list[dict[str, object]]: + parsed = urlparse(pr_url) + parts = [part for part in parsed.path.split("/") if part] + if parsed.hostname not in {"github.com", "www.github.com"} or len(parts) != 4: + raise ValueError("cannot derive GitHub repository from pr_url") + repository = "{}/{}".format(parts[0], parts[1]) + number = parts[3] + issue_comments = _paginate_feedback( + gh_bin, + workspace, + f"repos/{repository}/issues/{number}/comments?per_page=100", + timeout=timeout, + ) + reviews = _paginate_feedback( + gh_bin, + workspace, + f"repos/{repository}/pulls/{number}/reviews?per_page=100", + timeout=timeout, + ) + review_comments = _paginate_feedback( + gh_bin, + workspace, + f"repos/{repository}/pulls/{number}/comments?per_page=100", + timeout=timeout, + ) + review_threads = _read_review_threads( + gh_bin, + workspace, + repository, + number, + timeout=timeout, + ) + if ( + len(issue_comments) + + len(reviews) + + len(review_comments) + + len(review_threads) + + sum(len(item["comment_ids"]) for item in review_threads) + > 1000 + ): + raise ValueError("feedback_hard_limit") + items = [ + _feedback_item("issue_comment", item) for item in issue_comments + ] + items.extend(_feedback_item("review", item) for item in reviews) + items.extend( + _feedback_item("review_comment", item) for item in review_comments + ) + items.extend(review_threads) + runtime = runtime_contracts() + normalized = [ + runtime.validate_pr_feedback_item(item) for item in items + ] + identities = [(item["kind"], item["id"]) for item in normalized] + if len(identities) != len(set(identities)): + raise RuntimeError("duplicate_feedback_item") + normalized.sort(key=lambda item: (item["kind"], item["id"])) + return normalized + + +def feedback_checks(state: dict[str, object]) -> list[dict[str, object]]: + raw_checks = state.get("statusCheckRollup") + if not isinstance(raw_checks, list): + return [] + if len(raw_checks) > 1000: + raise ValueError("feedback_hard_limit") + rows: list[dict[str, object]] = [] + for item in raw_checks: + if not isinstance(item, dict): + raise ValueError("malformed_feedback_check") + bucket = str( + item.get("bucket") + or item.get("conclusion") + or item.get("status") + or "" + ).lower() + if bucket in {"success", "completed", "pass"}: + bucket = "pass" + elif bucket in {"failure", "failed", "fail", "error"}: + bucket = "fail" + elif bucket in {"cancelled", "cancel"}: + bucket = "cancel" + elif bucket in {"skipped", "skipping"}: + bucket = "skipping" + else: + bucket = "pending" + rows.append( + { + "workflow_name": str( + item.get("workflow") + or item.get("workflow_name") + or "" + ), + "check_name": str( + item.get("name") + or item.get("context") + or "" + ), + "bucket": bucket, + "state": str( + item.get("state") + or item.get("conclusion") + or item.get("status") + or "UNKNOWN" + ).upper(), + "link": item.get("detailsUrl") or item.get("link"), + } + ) + return rows + + def resolve_gh_bin() -> str: configured = os.environ.get("KENT_GH_BIN") candidates = (configured,) if configured else ("gh", *DEFAULT_GH_PATHS) @@ -74,40 +913,46 @@ def read_state( *, timeout: int, ) -> dict[str, object]: - try: - result = subprocess.run( - [ - gh_bin, - "pr", - "view", - pr_url, - "--json", - FIELDS, - ], - cwd=workspace, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=timeout, - check=False, - ) - except subprocess.TimeoutExpired as error: - raise RuntimeError( - f"GitHub PR query exceeded {timeout} seconds" - ) from error - if result.returncode != 0: - detail = result.stderr.strip() or result.stdout.strip() or "no output" - raise RuntimeError(detail) - try: - payload = json.loads(result.stdout) - except json.JSONDecodeError as error: - raise RuntimeError(f"gh returned invalid JSON: {error}") from error + stdout, stderr, _exit_code = _run_query( + [ + gh_bin, + "pr", + "view", + pr_url, + "--json", + FIELDS, + ], + workspace, + timeout=timeout, + ) + payload = _json_output(stdout, label="GitHub PR query", stderr=stderr) if not isinstance(payload, dict): - raise RuntimeError("gh returned a non-object PR payload") + raise QueryError("github_output_invalid", exit_code=0, stdout=stdout) return payload -def state_report(state: dict[str, object], reason: str) -> str: +def _safe_error(error: Exception) -> dict[str, object]: + if isinstance(error, QueryError): + return { + "code": error.code, + "exit_code": error.exit_code, + "stdout_sha256": hashlib.sha256(error.stdout).hexdigest(), + "stderr_sha256": hashlib.sha256(error.stderr).hexdigest(), + } + return { + "code": "github_output_invalid", + "exit_code": None, + "stdout_sha256": hashlib.sha256(b"").hexdigest(), + "stderr_sha256": hashlib.sha256(b"").hexdigest(), + } + + +def state_report( + state: dict[str, object], + reason: str, + *, + feedback_error: Exception | None = None, +) -> str: checks = [] raw_checks = state.get("statusCheckRollup") if isinstance(raw_checks, list): @@ -121,17 +966,20 @@ def state_report(state: dict[str, object], reason: str) -> str: "conclusion": check.get("conclusion"), } ) + report: dict[str, object] = { + "reason": reason, + "state": state.get("state"), + "headRefOid": state.get("headRefOid"), + "baseRefOid": state.get("baseRefOid"), + "reviewDecision": state.get("reviewDecision"), + "mergeStateStatus": state.get("mergeStateStatus"), + "checks": checks, + "url": state.get("url"), + } + if feedback_error is not None: + report["safe_error"] = _safe_error(feedback_error) return json.dumps( - { - "reason": reason, - "state": state.get("state"), - "headRefOid": state.get("headRefOid"), - "baseRefOid": state.get("baseRefOid"), - "reviewDecision": state.get("reviewDecision"), - "mergeStateStatus": state.get("mergeStateStatus"), - "checks": checks, - "url": state.get("url"), - }, + report, ensure_ascii=False, sort_keys=True, ) @@ -151,6 +999,18 @@ def main() -> int: merge_strategy = require_string(raw, "merge_strategy") initial_head = require_string(raw, "pr_head_oid") initial_base = require_string(raw, "pr_base_oid") + v2_mode = not ( + profile_schema3_legacy(workspace) and "ci_report" not in raw + ) + prevalidation_error: Exception | None = None + cursor: dict[str, object] = {} + if v2_mode: + try: + cursor = cursor_values(raw) + except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as error: + prevalidation_error = error + else: + cursor = cursor_values(raw) validate_pr_url(pr_url) if not workspace.is_dir(): raise ValueError(f"workspace does not exist: {workspace}") @@ -182,6 +1042,21 @@ def main() -> int: 1, int(os.environ.get("KENT_PR_WATCH_MAX_ERRORS", "5")), ) + if v2_mode and prevalidation_error is not None: + workflow_result( + "merge_watch_state_changed", + workspace_path=str(workspace), + pr_url=pr_url, + branch_name=branch_name, + merge_strategy=merge_strategy, + pr_report=state_report( + {}, + "pull_request_feedback_invalid", + feedback_error=prevalidation_error, + ), + pr_feedback_cursor="uninitialized", + ) + return 0 gh_bin = resolve_gh_bin() consecutive_errors = 0 poll = 0 @@ -196,23 +1071,32 @@ def main() -> int: timeout=query_timeout, ) consecutive_errors = 0 - except RuntimeError as error: + except (QueryError, RuntimeError, ValueError) as error: consecutive_errors += 1 if consecutive_errors >= max_errors: + if cursor: + safe = { + "reason": "github_query_failed", + "safe_error": _safe_error(error), + } + report = json.dumps(safe, ensure_ascii=False, sort_keys=True) + else: + report = json.dumps( + { + "reason": "github_query_failed", + "attempts": consecutive_errors, + "safe_error": _safe_error(error), + }, + ensure_ascii=False, + ) workflow_result( "merge_watch_state_changed", workspace_path=str(workspace), pr_url=pr_url, branch_name=branch_name, merge_strategy=merge_strategy, - pr_report=json.dumps( - { - "reason": "github_query_failed", - "attempts": consecutive_errors, - "error": str(error), - }, - ensure_ascii=False, - ), + pr_report=report, + **cursor, ) return 0 print( @@ -228,6 +1112,45 @@ def main() -> int: base_oid = str(state.get("baseRefOid") or "") review_decision = str(state.get("reviewDecision") or "").upper() merge_state = str(state.get("mergeStateStatus") or "").upper() + feedback_reason = "" + feedback_error: Exception | None = None + if cursor: + try: + previous_cursor = feedback_cursor(raw) + state["feedback_items"] = read_feedback( + workspace, + pr_url, + gh_bin, + timeout=query_timeout, + ) + state["feedback_checks"] = feedback_checks(state) + current_cursor = materialize_cursor(raw, state, pr_url) + cursor = { + "pr_feedback_cursor": serialize_cursor(current_cursor) + } + if current_cursor != previous_cursor: + feedback_transition = runtime_contracts().classify_pr_feedback( + previous_cursor, + current_cursor, + )["transition"] + if feedback_transition == "state_changed": + feedback_reason = "pull_request_feedback_changed" + elif feedback_transition == "feedback_hard_limit": + feedback_reason = "pull_request_feedback_hard_limit" + except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as error: + feedback_error = error + feedback_reason = ( + "pull_request_feedback_hard_limit" + if ( + str(error) + in {"feedback_hard_limit", "feedback_body_hard_limit"} + or ( + isinstance(error, QueryError) + and error.code == "hard_limit" + ) + ) + else "pull_request_feedback_invalid" + ) checks = state.get("statusCheckRollup") failed_checks = [] if isinstance(checks, list): @@ -263,6 +1186,7 @@ def main() -> int: ensure_ascii=False, sort_keys=True, ), + **cursor, ) return 0 @@ -281,6 +1205,8 @@ def main() -> int: reason = "merge_conflict" elif state_name not in {"OPEN", ""}: reason = "unexpected_pull_request_state" + elif feedback_reason: + reason = feedback_reason if reason: workflow_result( @@ -289,7 +1215,12 @@ def main() -> int: pr_url=pr_url, branch_name=branch_name, merge_strategy=merge_strategy, - pr_report=state_report(state, reason), + pr_report=state_report( + state, + reason, + feedback_error=feedback_error, + ), + **cursor, ) return 0 @@ -302,6 +1233,7 @@ def main() -> int: merge_strategy=merge_strategy, pr_head_oid=initial_head, pr_base_oid=initial_base, + **cursor, ) return 0 diff --git a/.kent/scripts/workflow-wait-github-release b/.kent/scripts/workflow-wait-github-release index 531f44a8..d3ba9401 100755 --- a/.kent/scripts/workflow-wait-github-release +++ b/.kent/scripts/workflow-wait-github-release @@ -1,20 +1,67 @@ #!/usr/bin/env python3 from __future__ import annotations - -import json -import os -import subprocess -import sys +import json, os, re, subprocess, sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from workflow_runtime_contracts import ContractError, emit, operation, read_object, required - +SCHEMA_VERSION = 4 +for _name in ("KENT_SESSION_ID", "KENT_RUN_ID", "KENT_STEP_ID"): + os.environ.pop(_name, None) +WORKFLOW_ID = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" +NODE = 'monitor' +EXPECTED_TRANSITION = 'publish_monitor' +KENT_KEYS = {"task_short_id", "project_id", "workflow_id", "workflow_revision", "current_node_key", "execution_commit", "worktree_path", "incoming_transition", "authority_transition"} +class ContractError(ValueError): pass +def read_object(): + raw=sys.stdin.read() + if not raw.strip(): raise ContractError("stdin must contain exactly one JSON object") + try: value=json.loads(raw) + except json.JSONDecodeError as e: raise ContractError(f"stdin is invalid JSON: {e}") from e + if not isinstance(value,dict): raise ContractError("stdin must contain one JSON object") + if os.environ.get("KENT_SESSION_ID") or os.environ.get("KENT_RUN_ID") or os.environ.get("KENT_STEP_ID"): + raise ContractError("inherited Kent identity must be cleared before child reads") + return value +def required(p,k): + v=p.get(k) + if not isinstance(v,str) or not v.strip(): raise ContractError(f"{k} must be a non-empty string") + return v.strip() +def authority(p): + a=p.get("_kent") + if not isinstance(a,dict) or set(a)!=KENT_KEYS: raise ContractError("_kent authority shape is missing, extra, or stale") + if a["workflow_id"]!=WORKFLOW_ID or a["workflow_revision"]!=88 or a["current_node_key"]!=NODE or a["incoming_transition"]!=EXPECTED_TRANSITION: + raise ContractError("_kent authority does not match this node and revision") + for k in ("task_short_id","project_id","execution_commit","worktree_path","authority_transition"): + required(a,k) + return a +def operation(p): + a=authority(p); return {"workflow_id":WORKFLOW_ID,"workflow_revision":88,"node_key":NODE,"task_short_id":a["task_short_id"],"authority_transition":a["authority_transition"]} +def emit(transition, **fields): + print(json.dumps({"schema_version":SCHEMA_VERSION,"transition":transition,**fields},ensure_ascii=False,sort_keys=True)) +def root(path): + r=Path(path).expanduser().resolve() + if not r.is_dir(): raise ContractError(f"workspace does not exist: {r}") + result=subprocess.run(["git","rev-parse","--show-toplevel"],cwd=r,text=True,capture_output=True,check=False) + if result.returncode or Path(result.stdout.strip()).resolve()!=r: raise ContractError("workspace must be the exact repository root") + return r +def git(r,*args): + x=subprocess.run(["git",*args],cwd=r,text=True,capture_output=True,check=False) + if x.returncode: raise ContractError(x.stderr.strip() or x.stdout.strip() or "git failed") + return x.stdout.strip() def main() -> int: try: payload = read_object() - op = operation(payload, "monitor") + op = operation(payload) + workspace = root(required(payload, "workspace_path")) + authority_data = authority(payload) + if authority_data["worktree_path"] != str(workspace): + raise ContractError("_kent worktree_path does not match workspace_path") + if authority_data["execution_commit"] != git(workspace, "rev-parse", "HEAD"): + raise ContractError("_kent execution_commit does not match selected revision") + run_tuple = payload.get("release_run") + if not isinstance(run_tuple, dict) or set(run_tuple) != {"repository", "workflow_path", "workflow_name", "event", "run_id", "attempt", "head_sha", "ref"}: + raise ContractError("release_run must be one immutable exact GitHub run tuple") + if run_tuple["repository"] != "rovkinmax/Puber" or run_tuple["workflow_path"] != ".github/workflows/release.yml" or run_tuple["workflow_name"] != "Release" or run_tuple["event"] != "push": + raise ContractError("release_run workflow identity drifted") tag = required(payload, "release_tag") target = required(payload, "target_commit") if not tag.startswith("v"): @@ -23,7 +70,7 @@ def main() -> int: if os.environ.get("PUBER_RELEASE_TEST_MODE") == "1" or fixture is not None: state = fixture if isinstance(fixture, dict) else {"tag": tag, "target_commit": target, "published": False} emit( - "release_watch_reported", operation_data=op, release_tag=tag, + "release_watch_reported", operation=op, release_tag=tag, target_commit=target, release_report=json.dumps(state, sort_keys=True), no_live_apply=True, ) @@ -36,7 +83,7 @@ def main() -> int: if result.returncode != 0: raise ContractError(result.stderr.strip() or "GitHub Release lookup failed") emit( - "release_watch_reported", operation_data=op, release_tag=tag, + "release_watch_reported", operation=op, release_tag=tag, target_commit=target, release_report=result.stdout.strip(), no_live_apply=True, ) except (ContractError, OSError) as error: diff --git a/.kent/scripts/workflow_runtime_contracts.py b/.kent/scripts/workflow_runtime_contracts.py index 962e5048..517cab89 100755 --- a/.kent/scripts/workflow_runtime_contracts.py +++ b/.kent/scripts/workflow_runtime_contracts.py @@ -1,86 +1,3727 @@ -#!/usr/bin/env python3 -"""Offline schema-4 operation-carrier helpers for the Puber release scripts.""" -from __future__ import annotations +"""Pure, closed runtime contracts shared by the Kit and project adapters. +This module intentionally has no package-relative imports and no filesystem, +Git, Kent, network, clock, randomness, journal, or write effects. It is also +the source copied to a project's sibling runtime support module. +""" + +import base64 +from dataclasses import dataclass import hashlib import json -from pathlib import Path -import subprocess -import sys -from typing import Any, Mapping +import math +import re +from types import MappingProxyType +from typing import Any, Iterable, Mapping, Sequence + + +class RuntimeContractError(ValueError): + """Raised when a runtime contract is malformed or inconsistent.""" + + +RuntimeValidationError = RuntimeContractError + + +class _FrozenList(tuple): + """Private marker preserving list semantics during materialization.""" + + +def _freeze_classification_value(value: Any) -> Any: + if isinstance(value, Mapping): + return MappingProxyType( + { + key: _freeze_classification_value(item) + for key, item in value.items() + } + ) + if isinstance(value, list): + return _FrozenList( + _freeze_classification_value(item) for item in value + ) + if isinstance(value, tuple): + return tuple(_freeze_classification_value(item) for item in value) + return value -RUNTIME_SCHEMA_VERSION = 4 -WORKFLOW_ID = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" + +def _thaw_classification_value(value: Any) -> Any: + if isinstance(value, Mapping): + return { + key: _thaw_classification_value(item) + for key, item in value.items() + } + if isinstance(value, _FrozenList): + return [_thaw_classification_value(item) for item in value] + if isinstance(value, tuple): + return tuple(_thaw_classification_value(item) for item in value) + return value + + +MAX_CAPTURE_STDIN_BYTES = 6 * 1024 * 1024 +MAX_EXTERNAL_ROOT_BYTES = 1024 * 1024 +MAX_EXTERNAL_TOTAL_BYTES = 4 * 1024 * 1024 +MAX_SOURCE_PREVIEW_BYTES = 4 * 1024 * 1024 +MAX_EXPECTED_CHECKS = 100 +MAX_EXPECTED_CHECKS_BYTES = 32 * 1024 +MAX_FEEDBACK_ITEMS = 100 +MAX_FEEDBACK_HARD_LIMIT = 1000 +MAX_FEEDBACK_BYTES = 64 * 1024 +MAX_CI_EXPECTED_OBSERVATIONS = 100 +MAX_CI_UNEXPECTED_OBSERVATIONS = 10000 +MAX_CI_ATTEMPTS = 8 +MAX_CI_REPORT_BYTES = 64 * 1024 +MAX_CI_ATTEMPT_BYTES = 48 * 1024 +MAX_OBSERVATION_CANONICAL_BYTES = 4 * 1024 * 1024 +MAX_CANONICAL_JSON_NESTING = 100 + +SOURCE_ENVELOPE_SCHEMA = "runtime-source-envelope-v1" +SELECTED_INPUTS_SCHEMA = "selected-runtime-source-inputs-v1" +EXTERNAL_CAPTURE_SCHEMA = "runtime-external-captures-v1" +TERMINAL_SEAL_REQUEST_SCHEMA = "terminal-evidence-seal-request-v1" +TERMINAL_SEAL_SCHEMA = "terminal_evidence_seal_v1" +TERMINAL_MARKER_SCHEMA = "terminal_evidence_v1" +VERIFICATION_REPORT_SCHEMA = "workflow-verification-report-v2" +PR_CURSOR_SCHEMA = "github-pr-feedback-cursor-v1" +EXPECTED_CHECKS_SCHEMA = "github-ci-expected-checks-v1" +CI_REPORT_SCHEMA = "github-ci-report-v2" -class ContractError(ValueError): - pass +@dataclass(frozen=True) +class RejectedObservationReceipt: + source: str + count: int + sha256: str + def __post_init__(self) -> None: + if self.source not in {"projected_rows", "unexpected_rows"}: + raise RuntimeContractError("unsupported rejected observation source") + if not isinstance(self.count, int) or isinstance(self.count, bool): + raise RuntimeContractError("rejected observation count must be an integer") + if not 0 <= self.count <= 2147483647: + raise RuntimeContractError("rejected observation count is out of range") + if not isinstance(self.sha256, str) or not SHA256_RE.fullmatch(self.sha256): + raise RuntimeContractError("rejected observation digest is invalid") + + +@dataclass(frozen=True) +class RejectedObservationHardLimit: + source: str + prefix_sha256: str + + def __post_init__(self) -> None: + if self.source not in {"projected_rows", "unexpected_rows"}: + raise RuntimeContractError("unsupported rejected observation source") + if not isinstance(self.prefix_sha256, str) or not SHA256_RE.fullmatch( + self.prefix_sha256 + ): + raise RuntimeContractError("rejected observation prefix is invalid") + + +@dataclass(frozen=True) +class ExpectedCiClassification: + state: str + value: Mapping[str, Any] | None + projected_observations: ( + RejectedObservationReceipt | RejectedObservationHardLimit | None + ) + unexpected_observations: ( + RejectedObservationReceipt | RejectedObservationHardLimit | None + ) + grammar_error: RuntimeContractError | None = None + + def __post_init__(self) -> None: + if self.state not in { + "ordinary", + "observation_limit", + "grammar_invalid", + "hard_limit", + }: + raise RuntimeContractError("unsupported expected CI classification state") + if self.state in {"ordinary", "observation_limit"}: + if not isinstance(self.value, Mapping): + raise RuntimeContractError( + "expected CI classification value must be a mapping" + ) + object.__setattr__( + self, + "value", + _freeze_classification_value(self.value), + ) + elif self.value is not None: + raise RuntimeContractError( + "bounded expected CI classifications must not carry a value" + ) + if self.state == "grammar_invalid": + if not isinstance(self.grammar_error, RuntimeContractError): + raise RuntimeContractError( + "grammar-invalid classifications require a grammar error" + ) + if not isinstance( + self.projected_observations, + RejectedObservationReceipt, + ) or self.projected_observations.source != "projected_rows" or ( + self.unexpected_observations is not None + ): + raise RuntimeContractError( + "grammar-invalid classifications require projected observations" + ) + elif self.state == "observation_limit": + if not isinstance( + self.projected_observations, + RejectedObservationReceipt, + ) or not isinstance( + self.unexpected_observations, + RejectedObservationReceipt, + ) or self.projected_observations.source != "projected_rows" or ( + self.unexpected_observations.source != "unexpected_rows" + ): + raise RuntimeContractError( + "observation-limit classifications require both receipts" + ) + if self.grammar_error is not None: + raise RuntimeContractError( + "observation-limit classifications must not carry a grammar error" + ) + elif self.state == "hard_limit": + projected_hard = isinstance( + self.projected_observations, + RejectedObservationHardLimit, + ) + unexpected_hard = isinstance( + self.unexpected_observations, + RejectedObservationHardLimit, + ) + projected_receipt = isinstance( + self.projected_observations, + RejectedObservationReceipt, + ) + valid_projected_hard = ( + projected_hard + and self.projected_observations.source == "projected_rows" + and self.unexpected_observations is None + ) + valid_unexpected_hard = ( + projected_receipt + and self.projected_observations.source == "projected_rows" + and unexpected_hard + and self.unexpected_observations.source == "unexpected_rows" + ) + if not (valid_projected_hard or valid_unexpected_hard): + raise RuntimeContractError( + "hard-limit classification has an unreachable receipt shape" + ) + if self.grammar_error is not None: + raise RuntimeContractError( + "hard-limit classifications must not carry a grammar error" + ) + else: + if not isinstance( + self.projected_observations, + (RejectedObservationReceipt, type(None)), + ) or ( + isinstance( + self.projected_observations, + RejectedObservationReceipt, + ) + and self.projected_observations.source != "projected_rows" + ) or self.unexpected_observations is not None: + raise RuntimeContractError( + "ordinary classifications require only projected observations" + ) + if self.grammar_error is not None: + raise RuntimeContractError( + "ordinary classifications must not carry a grammar error" + ) + + def materialize_value(self) -> dict[str, Any]: + if self.value is None: + raise RuntimeContractError("classification has no ordinary value") + materialized = _thaw_classification_value(self.value) + if not isinstance(materialized, dict): + raise RuntimeContractError( + "classification value did not materialize as a mapping" + ) + return materialized + + +class CiAttemptSizeLimit(RuntimeContractError): + """Raised only when a canonical CI attempt exceeds its wire limit.""" + +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +SHA1_RE = re.compile(r"^[0-9a-f]{40}$") +TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +REPOSITORY_RE = re.compile(r"^[^/\s]{1,100}/[^/\s]{1,100}$") +UPPER_TOKEN_RE = re.compile(r"^[A-Z_]{1,64}$") +IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +LOG_PATH_RE = re.compile( + r"^build/kent-workflow/verification-report-[0-9a-f]{64}\.log$" +) +LINK_RE = re.compile( + r"^https://(?:github\.com|www\.github\.com)(?:/|$)" +) +RFC3339_RE = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}" + r"(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$" +) + + +def canonical_bytes(value: Any) -> bytes: + """Return compact, UTF-8, sorted-key JSON bytes without NaN values.""" -def read_object() -> dict[str, Any]: try: - value = json.load(sys.stdin) - except json.JSONDecodeError as error: - raise ContractError(f"stdin must be one JSON object: {error}") from error - if not isinstance(value, dict): - raise ContractError("stdin must be one JSON object") + return b"".join(_canonical_byte_chunks(value)) + except RuntimeContractError: + raise + except RecursionError as error: + raise RuntimeContractError( + "canonical JSON nesting exceeds its limit" + ) from error + except (TypeError, ValueError, UnicodeEncodeError) as error: + raise RuntimeContractError( + f"value is not canonical JSON: {error}" + ) from error + + +canonical_json_bytes = canonical_bytes + + +def sha256_bytes(value: bytes) -> str: + if not isinstance(value, bytes): + raise RuntimeContractError("digest input must be bytes") + return hashlib.sha256(value).hexdigest() + + +def canonical_sha256(value: Any) -> str: + return sha256_bytes(canonical_bytes(value)) + + +_CANONICAL_TEXT_FRAGMENT_CHARS = 1024 +_CANONICAL_ESCAPES = { + '"': '\\"', + "\\": "\\\\", + "\b": "\\b", + "\f": "\\f", + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", +} + + +def _iter_canonical_string(value: str) -> Iterable[str]: + yield '"' + fragment: list[str] = [] + fragment_length = 0 + for index, character in enumerate(value): + codepoint = ord(character) + if 0xD800 <= codepoint <= 0xDFFF: + raise UnicodeEncodeError( + "utf-8", + value, + index, + index + 1, + "surrogates not allowed", + ) + escaped = _CANONICAL_ESCAPES.get(character) + if escaped is None: + escaped = ( + "\\u{:04x}".format(codepoint) + if codepoint < 0x20 + else character + ) + if ( + fragment + and fragment_length + len(escaped) + > _CANONICAL_TEXT_FRAGMENT_CHARS + ): + yield "".join(fragment) + fragment = [] + fragment_length = 0 + fragment.append(escaped) + fragment_length += len(escaped) + if fragment: + yield "".join(fragment) + yield '"' + + +def _canonical_object_key(value: Any) -> str: + if isinstance(value, str): + return value + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError("out-of-range float values are not JSON compliant") + return json.dumps( + value, + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ) + raise TypeError( + "keys must be str, int, float, bool or None, not {}".format( + type(value).__name__ + ) + ) + + +def _iter_canonical_fragments( + value: Any, + active: set[int] | None = None, + nesting: int = 0, +) -> Iterable[str]: + if active is None: + active = set() + if value is None: + yield "null" + elif value is True: + yield "true" + elif value is False: + yield "false" + elif isinstance(value, int): + yield str(value) + elif isinstance(value, float): + if not math.isfinite(value): + raise ValueError("out-of-range float values are not JSON compliant") + yield json.dumps( + value, + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ) + elif isinstance(value, str): + yield from _iter_canonical_string(value) + elif isinstance(value, (list, tuple)): + next_nesting = nesting + 1 + if next_nesting > MAX_CANONICAL_JSON_NESTING: + raise RuntimeContractError( + "canonical JSON nesting exceeds its limit" + ) + identity = id(value) + if identity in active: + raise ValueError("Circular reference detected") + active.add(identity) + try: + yield "[" + for index, item in enumerate(value): + if index: + yield "," + yield from _iter_canonical_fragments( + item, + active, + next_nesting, + ) + yield "]" + finally: + active.remove(identity) + elif isinstance(value, dict): + next_nesting = nesting + 1 + if next_nesting > MAX_CANONICAL_JSON_NESTING: + raise RuntimeContractError( + "canonical JSON nesting exceeds its limit" + ) + identity = id(value) + if identity in active: + raise ValueError("Circular reference detected") + active.add(identity) + try: + try: + keys = sorted(value) + except TypeError: + raise TypeError("keys are not mutually comparable") from None + yield "{" + for index, key in enumerate(keys): + if index: + yield "," + yield from _iter_canonical_string(_canonical_object_key(key)) + yield ":" + yield from _iter_canonical_fragments( + value[key], + active, + next_nesting, + ) + yield "}" + finally: + active.remove(identity) + else: + raise TypeError( + "Object of type {} is not JSON serializable".format( + type(value).__name__ + ) + ) + + +def _canonical_byte_chunks(value: Any) -> Iterable[bytes]: + for fragment in _iter_canonical_fragments(value): + for offset in range(0, len(fragment), _CANONICAL_TEXT_FRAGMENT_CHARS): + piece = fragment[offset : offset + _CANONICAL_TEXT_FRAGMENT_CHARS] + yield piece.encode("utf-8") + + +def _bounded_canonical_observation( + rows: Sequence[Mapping[str, Any]], + source: str, +) -> RejectedObservationReceipt | RejectedObservationHardLimit: + if source not in {"projected_rows", "unexpected_rows"}: + raise RuntimeContractError("unsupported observation receipt source") + digest = hashlib.sha256() + retained = 0 + limit = MAX_OBSERVATION_CANONICAL_BYTES + 1 + try: + for encoded in _canonical_byte_chunks(rows): + remaining = limit - retained + prefix = encoded[:remaining] + if prefix: + digest.update(prefix) + retained += len(prefix) + if retained == limit: + return RejectedObservationHardLimit(source, digest.hexdigest()) + except RecursionError as error: + raise RuntimeContractError( + "canonical JSON nesting exceeds its limit" + ) from error + except (TypeError, ValueError, UnicodeEncodeError) as error: + raise RuntimeContractError( + f"observations are not canonical JSON: {error}" + ) from error + return RejectedObservationReceipt(source, len(rows), digest.hexdigest()) + + +def parse_canonical_json( + raw: bytes | str, + *, + label: str = "JSON value", + max_bytes: int | None = None, +) -> Any: + if isinstance(raw, str): + raw_bytes = raw.encode("utf-8") + elif isinstance(raw, bytes): + raw_bytes = raw + else: + raise RuntimeContractError(f"{label} must be bytes or text") + if max_bytes is not None and len(raw_bytes) > max_bytes: + raise RuntimeContractError(f"{label} exceeds its byte limit") + try: + value = json.loads( + raw_bytes.decode("utf-8"), + parse_constant=lambda constant: ( + (_ for _ in ()).throw( + RuntimeContractError( + f"{label} contains invalid JSON constant {constant}" + ) + ) + ), + ) + except RuntimeContractError: + raise + except RecursionError as error: + raise RuntimeContractError( + f"{label} exceeds the canonical JSON nesting limit" + ) from error + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise RuntimeContractError(f"{label} is invalid JSON: {error}") from error + try: + encoded = canonical_bytes(value) + except RecursionError as error: + raise RuntimeContractError( + f"{label} exceeds the canonical JSON nesting limit" + ) from error + except RuntimeContractError as error: + if str(error) == "canonical JSON nesting exceeds its limit": + raise RuntimeContractError( + f"{label} exceeds the canonical JSON nesting limit" + ) from error + raise + if encoded != raw_bytes: + raise RuntimeContractError(f"{label} is not canonical JSON") return value -def required(payload: Mapping[str, Any], key: str, *, allow_empty: bool = False) -> str: - value = payload.get(key) +canonicalize_json = canonical_bytes + + +def _object(value: Any, label: str) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise RuntimeContractError(f"{label} must be an object") + return dict(value) + + +def _closed(value: Any, keys: set[str], label: str) -> dict[str, Any]: + result = _object(value, label) + unknown = sorted(set(result) - keys) + if unknown: + raise RuntimeContractError(f"{label} has unknown fields: {unknown}") + return result + + +def _required(value: Mapping[str, Any], key: str, label: str) -> Any: + if key not in value: + raise RuntimeContractError(f"{label} is missing {key!r}") + return value[key] + + +def _string( + value: Any, + label: str, + *, + nonempty: bool = True, + max_bytes: int | None = None, +) -> str: if not isinstance(value, str): - raise ContractError(f"{key} must be a string") - value = value.strip() - if not allow_empty and not value: - raise ContractError(f"{key} must be non-empty") + raise RuntimeContractError(f"{label} must be a string") + if nonempty and not value: + raise RuntimeContractError(f"{label} must be non-empty") + if any(ord(character) < 32 or ord(character) == 127 for character in value): + raise RuntimeContractError(f"{label} contains a control character") + encoded_length = len(value.encode("utf-8")) + if max_bytes is not None and encoded_length > max_bytes: + raise RuntimeContractError( + f"{label} exceeds the {max_bytes}-byte limit" + ) + return value + + +def _integer(value: Any, label: str, *, minimum: int = 0) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < minimum: + raise RuntimeContractError( + f"{label} must be an integer >= {minimum}" + ) + return value + + +def _boolean(value: Any, label: str) -> bool: + if not isinstance(value, bool): + raise RuntimeContractError(f"{label} must be a boolean") + return value + + +def _digest(value: Any, label: str) -> str: + value = _string(value, label, max_bytes=64) + if not SHA256_RE.fullmatch(value): + raise RuntimeContractError(f"{label} must be lowercase 64-hex") + return value + + +def _commit(value: Any, label: str, *, nullable: bool = False) -> str | None: + if value is None and nullable: + return None + value = _string(value, label, max_bytes=40) + if not SHA1_RE.fullmatch(value): + raise RuntimeContractError(f"{label} must be lowercase 40-hex") + return value + + +def _sorted_unique_strings( + value: Any, + label: str, + *, + max_items: int | None = None, +) -> tuple[str, ...]: + if not isinstance(value, list): + raise RuntimeContractError(f"{label} must be an array") + if max_items is not None and len(value) > max_items: + raise RuntimeContractError(f"{label} exceeds its item limit") + result = tuple(_string(item, f"{label}[]") for item in value) + if result != tuple(sorted(set(result))): + raise RuntimeContractError(f"{label} must be sorted and unique") + return result + + +@dataclass(frozen=True) +class RuntimeExternalRoot: + kind: str + key: str + runtime_digest_required: bool = True + + def __post_init__(self) -> None: + _string(self.kind, "external_root.kind") + _string(self.key, "external_root.key") + if not self.runtime_digest_required: + raise RuntimeContractError( + "external_root.runtime_digest_required must be true" + ) + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> "RuntimeExternalRoot": + data = _closed( + value, + {"kind", "key", "runtime_digest_required"}, + "external_root", + ) + if set(data) != {"kind", "key", "runtime_digest_required"}: + raise RuntimeContractError( + "external_root has an incomplete field set" + ) + return cls( + kind=_string(_required(data, "kind", "external_root"), "kind"), + key=_string(_required(data, "key", "external_root"), "key"), + runtime_digest_required=_boolean( + _required(data, "runtime_digest_required", "external_root"), + "runtime_digest_required", + ), + ) + + def as_dict(self) -> dict[str, Any]: + return { + "kind": self.kind, + "key": self.key, + "runtime_digest_required": self.runtime_digest_required, + } + + +_RUNTIME_PROOF_TOKEN = object() + + +@dataclass(frozen=True, init=False) +class SelectedRuntimeSourceInputs: + """A selected-revision source bundle carrying a private in-process proof.""" + + project_name: str + repository: str + topology_kind: str + project_commit: str + source_preview_bytes: bytes + source_preview_sha256: str + artifact_digests: Mapping[str, str] + external_roots: tuple[RuntimeExternalRoot, ...] + _proof: object + + def __init__( + self, + project_name: str, + repository: str, + topology_kind: str, + project_commit: str, + source_preview_bytes: bytes, + source_preview_sha256: str, + artifact_digests: Mapping[str, str], + external_roots: Sequence[RuntimeExternalRoot], + *, + _proof: object | None = None, + ) -> None: + if _proof is not _RUNTIME_PROOF_TOKEN: + raise RuntimeContractError( + "SelectedRuntimeSourceInputs may only be created by " + "selected-revision preflight" + ) + project_name = _string(project_name, "project_name") + repository = _string(repository, "repository") + if not REPOSITORY_RE.fullmatch(repository): + raise RuntimeContractError("repository has an invalid shape") + topology_kind = _string(topology_kind, "topology_kind") + project_commit = _commit(project_commit, "project_commit") + if not isinstance(source_preview_bytes, bytes): + raise RuntimeContractError("source_preview_bytes must be bytes") + if len(source_preview_bytes) > MAX_SOURCE_PREVIEW_BYTES: + raise RuntimeContractError("source preview exceeds its byte limit") + if _digest(source_preview_sha256, "source_preview_sha256") != ( + sha256_bytes(source_preview_bytes) + ): + raise RuntimeContractError( + "source_preview_sha256 does not match source_preview_bytes" + ) + digests = dict(artifact_digests) + required = { + "spec_raw_blob_sha256", + "source_manifest_raw_blob_sha256", + "snapshot_raw_blob_sha256", + } + allowed = required | {"builder_raw_blob_sha256"} + if set(digests) - allowed or not required <= set(digests): + raise RuntimeContractError( + "artifact_digests has an invalid field set" + ) + for key, value in digests.items(): + _digest(value, f"artifact_digests.{key}") + roots = tuple(external_roots) + if any(not isinstance(root, RuntimeExternalRoot) for root in roots): + raise RuntimeContractError("external_roots are not typed descriptors") + root_keys = [(root.kind, root.key) for root in roots] + if root_keys != sorted(root_keys) or len(set(root_keys)) != len(root_keys): + raise RuntimeContractError( + "external_roots must be sorted and unique" + ) + object.__setattr__(self, "project_name", project_name) + object.__setattr__(self, "repository", repository) + object.__setattr__(self, "topology_kind", topology_kind) + object.__setattr__(self, "project_commit", project_commit) + object.__setattr__(self, "source_preview_bytes", source_preview_bytes) + object.__setattr__(self, "source_preview_sha256", source_preview_sha256) + object.__setattr__( + self, + "artifact_digests", + MappingProxyType(dict(sorted(digests.items()))), + ) + object.__setattr__(self, "external_roots", roots) + object.__setattr__(self, "_proof", _proof) + + def _core_dict(self) -> dict[str, Any]: + return { + "schema": SELECTED_INPUTS_SCHEMA, + "project_name": self.project_name, + "repository": self.repository, + "topology_kind": self.topology_kind, + "project_commit": self.project_commit, + "source_preview_sha256": self.source_preview_sha256, + "artifact_digests": dict(self.artifact_digests), + "external_roots": [ + root.as_dict() for root in self.external_roots + ], + } + + @property + def selected_runtime_source_inputs_sha256(self) -> str: + return canonical_sha256(self._core_dict()) + + @property + def commit_oid(self) -> str: + return self.project_commit + + def as_dict(self) -> dict[str, Any]: + result = self._core_dict() + result["selected_runtime_source_inputs_sha256"] = ( + self.selected_runtime_source_inputs_sha256 + ) + return result + + def __json__(self) -> dict[str, Any]: + return self.as_dict() + + +def _make_selected_runtime_source_inputs( + *, + project_name: str, + repository: str, + topology_kind: str, + project_commit: str, + source_preview: Any, + artifact_digests: Mapping[str, str], + external_roots: Sequence[RuntimeExternalRoot], +) -> SelectedRuntimeSourceInputs: + preview_bytes = canonical_bytes(source_preview) + return SelectedRuntimeSourceInputs( + project_name, + repository, + topology_kind, + project_commit, + preview_bytes, + sha256_bytes(preview_bytes), + artifact_digests, + external_roots, + _proof=_RUNTIME_PROOF_TOKEN, + ) + + +def _require_proven_inputs( + value: Any, +) -> SelectedRuntimeSourceInputs: + if not isinstance(value, SelectedRuntimeSourceInputs): + raise RuntimeContractError( + "capture requires SelectedRuntimeSourceInputs from this module" + ) + if value._proof is not _RUNTIME_PROOF_TOKEN: + raise RuntimeContractError("runtime source proof is invalid") + return value + + +_SEALED_RUNTIME_PROOF = object() +_RUNTIME_SENTINELS = { + "runtime", + "dynamic", + "current", + "auto", + "any", + "unknown", + "unset", + "null", + "none", + "*", + "-", + "0", + "$runtime", + "${runtime}", + "", +} +_TASK_UUID_RE = re.compile( + r"^task-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" +) +_TASK_SHORT_ID_RE = re.compile(r"^[A-Z][A-Z0-9]+-[0-9]+$") +_AUTHORITY_KEY_RE = re.compile(r"^[a-z][a-z0-9_-]*$") +_SUPPORTED_GITHUB_EVENTS = { + "branch_protection_rule", + "check_run", + "check_suite", + "create", + "delete", + "deployment", + "deployment_status", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issue_comment", + "issues", + "label", + "merge_group", + "milestone", + "page_build", + "project", + "project_card", + "project_column", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_target", + "push", + "release", + "repository_dispatch", + "schedule", + "status", + "watch", + "workflow_call", + "workflow_dispatch", + "workflow_run", +} + + +def _policy_string(value: Any, label: str) -> str: + value = _string(value, label) + if value != value.strip(): + raise RuntimeContractError(f"{label} must be normalized") + if any(ord(character) < 32 or ord(character) == 127 for character in value): + raise RuntimeContractError(f"{label} contains a control character") + lowered = value.casefold() + if ( + "{" in value + or "}" in value + or value.startswith("$") + or (value.startswith("<") and value.endswith(">")) + or lowered in _RUNTIME_SENTINELS + ): + raise RuntimeContractError(f"{label} contains a runtime placeholder") return value -def operation(payload: Mapping[str, Any], node: str) -> dict[str, Any]: - supplied = payload.get("operation") - if supplied is not None and not isinstance(supplied, dict): - raise ContractError("operation must be an object") - result = dict(supplied or {}) - result.setdefault("workflow_id", WORKFLOW_ID) - result.setdefault("node_key", node) - result.setdefault("schema_version", RUNTIME_SCHEMA_VERSION) - if result["workflow_id"] != WORKFLOW_ID or result["schema_version"] != RUNTIME_SCHEMA_VERSION: - raise ContractError("operation authority is not the schema-4 Puber Release contract") - if not isinstance(result.get("operation_id"), str) or not result["operation_id"].strip(): - canonical = {key: value for key, value in payload.items() if key != "operation"} - encoded = json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode() - result["operation_id"] = hashlib.sha256(encoded).hexdigest() +def _runtime_path(value: Any, label: str) -> str: + value = _policy_string(value, label) + if value.startswith("/") or "\\" in value: + raise RuntimeContractError(f"{label} must be a project-relative path") + parts = value.split("/") + if any(part in {"", ".", ".."} for part in parts): + raise RuntimeContractError(f"{label} is not normalized") + return value + + +def _runtime_ref(value: Any, label: str) -> str: + value = _policy_string(value, label) + if not value.startswith(("refs/heads/", "refs/tags/")) or value.endswith("/"): + raise RuntimeContractError(f"{label} is not a normalized Git ref") + if any(character.isspace() for character in value): + raise RuntimeContractError(f"{label} must not contain whitespace") + if any(token in value for token in ("..", "@{", "//", "\\", "~", "^", ":", "?", "*", "[")): + raise RuntimeContractError(f"{label} contains forbidden ref syntax") + for component in value.split("/")[2:]: + if ( + not component + or component in {".", ".."} + or component.startswith(".") + or component.endswith(".") + or component.endswith(".lock") + ): + raise RuntimeContractError(f"{label} contains an invalid component") + return value + + +def _sealed_new(cls: type[Any], values: Mapping[str, Any]) -> Any: + instance = object.__new__(cls) + for key, value in values.items(): + object.__setattr__(instance, key, value) + return instance + + +@dataclass(frozen=True, init=False) +class RuntimeExecutionContext: + kind: str + task_id: str | None + task_short_id: str | None + workflow_id: str | None + workflow_revision: int | None + project_id: str | None + project_commit: str + authority_transition: str | None + repository: str | None + workflow_path: str | None + workflow_name: str | None + event: str | None + run_id: int | None + attempt: int | None + head_sha: str | None + ref: str | None + selected_runtime_source_inputs_sha256: str + execution_context_sha256: str + _proof: object + + def __init__(self, *args: Any, **kwargs: Any) -> None: + raise TypeError("RuntimeExecutionContext is sealed") + + def __repr__(self) -> str: + return ( + "RuntimeExecutionContext(" + f"kind={self.kind!r}, " + f"selected_runtime_source_inputs_sha256=" + f"{self.selected_runtime_source_inputs_sha256!r})" + ) + + +@dataclass(frozen=True, init=False) +class RuntimeAuthorityBinding: + authority: Mapping[str, Any] + repository: str + project_commit: str + selected_runtime_source_inputs_sha256: str + execution_context_sha256: str + runtime_source_envelope: Mapping[str, Any] + runtime_source_envelope_digest: str + provenance_fingerprint: str + _proof: object + + def __init__(self, *args: Any, **kwargs: Any) -> None: + raise TypeError("RuntimeAuthorityBinding is sealed") + + def __repr__(self) -> str: + return ( + "RuntimeAuthorityBinding(" + f"repository={self.repository!r}, " + f"project_commit={self.project_commit!r})" + ) + + +def _context_core(context: RuntimeExecutionContext) -> dict[str, Any]: + return { + "kind": context.kind, + "task_id": context.task_id, + "task_short_id": context.task_short_id, + "workflow_id": context.workflow_id, + "workflow_revision": context.workflow_revision, + "project_id": context.project_id, + "project_commit": context.project_commit, + "authority_transition": context.authority_transition, + "repository": context.repository, + "workflow_path": context.workflow_path, + "workflow_name": context.workflow_name, + "event": context.event, + "run_id": context.run_id, + "attempt": context.attempt, + "head_sha": context.head_sha, + "ref": context.ref, + "selected_runtime_source_inputs_sha256": ( + context.selected_runtime_source_inputs_sha256 + ), + } + + +def _require_context( + value: Any, + inputs: SelectedRuntimeSourceInputs, +) -> RuntimeExecutionContext: + inputs = _require_proven_inputs(inputs) + if not isinstance(value, RuntimeExecutionContext): + raise RuntimeContractError("runtime execution context is not sealed") + if any( + not hasattr(value, field) + for field in ("_proof", "selected_runtime_source_inputs_sha256") + ): + raise RuntimeContractError("runtime execution context is incomplete") + if value._proof is not _SEALED_RUNTIME_PROOF: + raise RuntimeContractError("runtime execution context proof is invalid") + if value.selected_runtime_source_inputs_sha256 != ( + inputs.selected_runtime_source_inputs_sha256 + ): + raise RuntimeContractError("runtime execution context uses another source") + if value.execution_context_sha256 != canonical_sha256(_context_core(value)): + raise RuntimeContractError("runtime execution context fingerprint is stale") + return value + + +def capture_runtime_execution_context( + inputs: SelectedRuntimeSourceInputs, + current_execution: Mapping[str, Any], +) -> RuntimeExecutionContext: + inputs = _require_proven_inputs(inputs) + data = dict(current_execution) if isinstance(current_execution, Mapping) else None + if data is None: + raise RuntimeContractError("current execution must be a mapping") + kind = data.get("kind") + if kind == "kent_transition": + if set(data) != { + "kind", "task_id", "task_short_id", "workflow_id", + "workflow_revision", "project_id", "project_commit", + "authority_transition", + }: + raise RuntimeContractError("Kent execution context has invalid fields") + task_id = _string(data["task_id"], "task_id") + if not _TASK_UUID_RE.fullmatch(task_id): + raise RuntimeContractError("task_id must be task-") + task_short_id = _string(data["task_short_id"], "task_short_id") + if not _TASK_SHORT_ID_RE.fullmatch(task_short_id): + raise RuntimeContractError("task_short_id has invalid grammar") + workflow_id = _string(data["workflow_id"], "workflow_id") + if not re.fullmatch( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}", + workflow_id, + ): + raise RuntimeContractError("workflow_id must be a UUID") + revision = data["workflow_revision"] + if not isinstance(revision, int) or isinstance(revision, bool) or revision <= 0: + raise RuntimeContractError("workflow_revision must be positive") + project_id = _string(data["project_id"], "project_id") + if not re.fullmatch( + r"project-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}", + project_id, + ): + raise RuntimeContractError("project_id must be project-") + commit = _commit(data["project_commit"], "project_commit") + if commit != inputs.project_commit: + raise RuntimeContractError("Kent context commit does not match selected source") + transition = _policy_string(data["authority_transition"], "authority_transition") + if not _AUTHORITY_KEY_RE.fullmatch(transition): + raise RuntimeContractError("authority_transition is not normalized") + values = { + "kind": kind, + "task_id": task_id, + "task_short_id": task_short_id, + "workflow_id": workflow_id, + "workflow_revision": revision, + "project_id": project_id, + "project_commit": commit, + "authority_transition": transition, + "repository": None, + "workflow_path": None, + "workflow_name": None, + "event": None, + "run_id": None, + "attempt": None, + "head_sha": None, + "ref": None, + "selected_runtime_source_inputs_sha256": ( + inputs.selected_runtime_source_inputs_sha256 + ), + "_proof": _SEALED_RUNTIME_PROOF, + } + elif kind == "github_run": + if set(data) != { + "kind", "repository", "workflow_path", "workflow_name", "event", + "run_id", "attempt", "head_sha", "ref", + }: + raise RuntimeContractError("GitHub execution context has invalid fields") + repository = _string(data["repository"], "repository") + if not REPOSITORY_RE.fullmatch(repository) or repository != inputs.repository: + raise RuntimeContractError("GitHub repository does not match selected source") + path = _runtime_path(data["workflow_path"], "workflow_path") + name = _policy_string(data["workflow_name"], "workflow_name") + event = _policy_string(data["event"], "event") + if event not in _SUPPORTED_GITHUB_EVENTS: + raise RuntimeContractError("GitHub event is unsupported") + run_id = data["run_id"] + attempt = data["attempt"] + if not isinstance(run_id, int) or isinstance(run_id, bool) or run_id <= 0: + raise RuntimeContractError("run_id must be positive") + if not isinstance(attempt, int) or isinstance(attempt, bool) or attempt <= 0: + raise RuntimeContractError("attempt must be positive") + head_sha = _commit(data["head_sha"], "head_sha") + if head_sha != inputs.project_commit: + raise RuntimeContractError("GitHub head_sha does not match selected source") + ref = _runtime_ref(data["ref"], "ref") + values = { + "kind": kind, + "task_id": None, + "task_short_id": None, + "workflow_id": None, + "workflow_revision": None, + "project_id": None, + "project_commit": inputs.project_commit, + "authority_transition": None, + "repository": repository, + "workflow_path": path, + "workflow_name": name, + "event": event, + "run_id": run_id, + "attempt": attempt, + "head_sha": head_sha, + "ref": ref, + "selected_runtime_source_inputs_sha256": ( + inputs.selected_runtime_source_inputs_sha256 + ), + "_proof": _SEALED_RUNTIME_PROOF, + } + else: + raise RuntimeContractError("current execution kind is unsupported") + values["execution_context_sha256"] = canonical_sha256( + {key: value for key, value in values.items() if key != "_proof"} + ) + return _sealed_new(RuntimeExecutionContext, values) + + +def _authority_values(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise RuntimeContractError("observed authority must be a mapping") + data = dict(value) + kind = data.get("kind") + if kind == "kent_transition": + required = { + "kind", "task_short_id", "workflow_id", "workflow_revision", + "project_id", "approval_authority", "authority_transition", + } + if set(data) != required: + raise RuntimeContractError("Kent authority has invalid fields") + if not _TASK_SHORT_ID_RE.fullmatch(_policy_string(data["task_short_id"], "task_short_id")): + raise RuntimeContractError("authority task_short_id is invalid") + if not re.fullmatch( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}", + _policy_string(data["workflow_id"], "workflow_id"), + ): + raise RuntimeContractError("authority workflow_id is invalid") + revision = data["workflow_revision"] + if not isinstance(revision, int) or isinstance(revision, bool) or revision <= 0: + raise RuntimeContractError("authority workflow_revision is invalid") + project_id = _policy_string(data["project_id"], "project_id") + if not project_id.startswith("project-"): + raise RuntimeContractError("authority project_id is invalid") + approval = _policy_string(data["approval_authority"], "approval_authority") + if not re.fullmatch(r"[a-z][a-z0-9_-]*", approval): + raise RuntimeContractError("approval_authority is not normalized") + transition = _policy_string(data["authority_transition"], "authority_transition") + if not _AUTHORITY_KEY_RE.fullmatch(transition): + raise RuntimeContractError("authority_transition is not normalized") + return { + "kind": "kent_transition", + "task_short_id": data["task_short_id"], + "workflow_id": data["workflow_id"], + "workflow_revision": revision, + "project_id": data["project_id"], + "approval_authority": approval, + "authority_transition": transition, + } + if kind == "github_run": + required = { + "kind", "workflow_path", "workflow_name", "event", "run_id", + "attempt", "head_sha", "ref", + } + if set(data) != required: + raise RuntimeContractError("GitHub authority has invalid fields") + path = _runtime_path(data["workflow_path"], "workflow_path") + name = _policy_string(data["workflow_name"], "workflow_name") + event = _policy_string(data["event"], "event") + if event not in _SUPPORTED_GITHUB_EVENTS: + raise RuntimeContractError("authority event is unsupported") + run_id = data["run_id"] + attempt = data["attempt"] + if not isinstance(run_id, int) or isinstance(run_id, bool) or run_id <= 0: + raise RuntimeContractError("authority run_id is invalid") + if not isinstance(attempt, int) or isinstance(attempt, bool) or attempt <= 0: + raise RuntimeContractError("authority attempt is invalid") + head_sha = _commit(data["head_sha"], "head_sha") + ref = _runtime_ref(data["ref"], "ref") + return { + "kind": "github_run", + "workflow_path": path, + "workflow_name": name, + "event": event, + "run_id": run_id, + "attempt": attempt, + "head_sha": head_sha, + "ref": ref, + } + raise RuntimeContractError("authority kind is unsupported") + + +def capture_runtime_authority_binding( + inputs: SelectedRuntimeSourceInputs, + captures: Iterable[tuple[str, str, bytes]], + execution_context: RuntimeExecutionContext, + observed_authority: Mapping[str, Any], +) -> RuntimeAuthorityBinding: + inputs = _require_proven_inputs(inputs) + context = _require_context(execution_context, inputs) + captured = capture_runtime_source_envelope(inputs, captures) + authority = _authority_values(observed_authority) + if authority["kind"] != context.kind: + raise RuntimeContractError("authority kind does not match execution context") + if context.kind == "kent_transition": + for key in ( + "task_short_id", + "workflow_id", + "workflow_revision", + "project_id", + "authority_transition", + ): + if authority[key] != getattr(context, key): + raise RuntimeContractError(f"Kent authority {key} does not match context") + repository = inputs.repository + else: + for key in ( + "workflow_path", + "workflow_name", + "event", + "run_id", + "attempt", + "head_sha", + "ref", + ): + if authority[key] != getattr(context, key): + raise RuntimeContractError(f"GitHub authority {key} does not match context") + repository = context.repository + envelope = _freeze_classification_value(captured["runtime_source_envelope"]) + authority_frozen = _freeze_classification_value(authority) + provenance = canonical_sha256( + { + "authority": authority, + "repository": repository, + "project_commit": inputs.project_commit, + "runtime_source_envelope": captured["runtime_source_envelope"], + "runtime_source_envelope_digest": captured[ + "runtime_source_envelope_digest" + ], + "selected_runtime_source_inputs_sha256": ( + inputs.selected_runtime_source_inputs_sha256 + ), + "execution_context_sha256": context.execution_context_sha256, + } + ) + return _sealed_new( + RuntimeAuthorityBinding, + { + "authority": authority_frozen, + "repository": repository, + "project_commit": inputs.project_commit, + "selected_runtime_source_inputs_sha256": ( + inputs.selected_runtime_source_inputs_sha256 + ), + "execution_context_sha256": context.execution_context_sha256, + "runtime_source_envelope": envelope, + "runtime_source_envelope_digest": captured[ + "runtime_source_envelope_digest" + ], + "provenance_fingerprint": provenance, + "_proof": _SEALED_RUNTIME_PROOF, + }, + ) + + +def _resolve_runtime_authority_binding( + binding: RuntimeAuthorityBinding, + execution_context: RuntimeExecutionContext, + *, + runtime_source_envelope_digest: str, +) -> Mapping[str, Any]: + if not isinstance(binding, RuntimeAuthorityBinding): + raise RuntimeContractError("runtime authority binding is not sealed") + if any( + not hasattr(binding, field) + for field in ("_proof", "authority", "runtime_source_envelope") + ): + raise RuntimeContractError("runtime authority binding is incomplete") + if binding._proof is not _SEALED_RUNTIME_PROOF: + raise RuntimeContractError("runtime authority binding proof is invalid") + if not isinstance(execution_context, RuntimeExecutionContext): + raise RuntimeContractError("runtime execution context is not sealed") + if any( + not hasattr(execution_context, field) + for field in ("_proof", "execution_context_sha256") + ): + raise RuntimeContractError("runtime execution context is incomplete") + if execution_context._proof is not _SEALED_RUNTIME_PROOF: + raise RuntimeContractError("runtime execution context proof is invalid") + _digest(runtime_source_envelope_digest, "runtime_source_envelope_digest") + _digest( + binding.selected_runtime_source_inputs_sha256, + "binding.selected_runtime_source_inputs_sha256", + ) + _commit(binding.project_commit, "binding.project_commit") + if binding.selected_runtime_source_inputs_sha256 != ( + execution_context.selected_runtime_source_inputs_sha256 + ): + raise RuntimeContractError("binding uses a different selected source") + if binding.execution_context_sha256 != execution_context.execution_context_sha256: + raise RuntimeContractError("binding uses a different execution context") + if binding.project_commit != execution_context.project_commit: + raise RuntimeContractError("binding uses a different project commit") + if ( + execution_context.kind == "github_run" + and binding.repository != execution_context.repository + ): + raise RuntimeContractError("binding uses a different repository") + if binding.runtime_source_envelope_digest != runtime_source_envelope_digest: + raise RuntimeContractError("runtime source envelope digest drifted") + if binding.execution_context_sha256 != canonical_sha256(_context_core(execution_context)): + raise RuntimeContractError("execution context fingerprint is stale") + envelope = _thaw_classification_value(binding.runtime_source_envelope) + if binding.runtime_source_envelope_digest != sha256_bytes( + canonical_bytes(envelope) + ): + raise RuntimeContractError("runtime source envelope fingerprint is stale") + authority = _authority_values(_thaw_classification_value(binding.authority)) + if authority["kind"] != execution_context.kind: + raise RuntimeContractError("binding authority kind is stale") + if execution_context.kind == "kent_transition": + for key in ( + "task_short_id", + "workflow_id", + "workflow_revision", + "project_id", + "authority_transition", + ): + if authority[key] != getattr(execution_context, key): + raise RuntimeContractError(f"binding authority {key} is stale") + else: + for key in ( + "workflow_path", + "workflow_name", + "event", + "run_id", + "attempt", + "head_sha", + "ref", + ): + if authority[key] != getattr(execution_context, key): + raise RuntimeContractError(f"binding authority {key} is stale") + if binding.provenance_fingerprint != canonical_sha256( + { + "authority": authority, + "repository": binding.repository, + "project_commit": binding.project_commit, + "runtime_source_envelope": envelope, + "runtime_source_envelope_digest": binding.runtime_source_envelope_digest, + "selected_runtime_source_inputs_sha256": ( + binding.selected_runtime_source_inputs_sha256 + ), + "execution_context_sha256": binding.execution_context_sha256, + } + ): + raise RuntimeContractError("runtime authority binding fingerprint is stale") + return { + "authority": authority, + "repository": binding.repository, + "project_commit": binding.project_commit, + "runtime_source_envelope_digest": binding.runtime_source_envelope_digest, + "selected_runtime_source_inputs_sha256": ( + binding.selected_runtime_source_inputs_sha256 + ), + "execution_context_sha256": binding.execution_context_sha256, + } + + +def _envelope_payload( + inputs: SelectedRuntimeSourceInputs, + captures: Sequence[tuple[str, str, bytes]], +) -> dict[str, Any]: + roots = [] + for (kind, key, contents), descriptor in zip( + captures, + inputs.external_roots, + ): + if kind != descriptor.kind or key != descriptor.key: + raise RuntimeContractError( + "external captures must match the proven root order" + ) + if not isinstance(contents, bytes): + raise RuntimeContractError( + "external capture contents must be bytes" + ) + if len(contents) > MAX_EXTERNAL_ROOT_BYTES: + raise RuntimeContractError("external root exceeds its byte limit") + roots.append( + { + "kind": kind, + "key": key, + "byte_count": len(contents), + "sha256": sha256_bytes(contents), + } + ) + result = { + "schema": SOURCE_ENVELOPE_SCHEMA, + "project_name": inputs.project_name, + "repository": inputs.repository, + "topology_kind": inputs.topology_kind, + "project_commit": inputs.project_commit, + "source_preview_sha256": inputs.source_preview_sha256, + "artifact_digests": dict(inputs.artifact_digests), + "external_roots": roots, + } return result -def emit(transition: str, *, operation_data: dict[str, Any] | None = None, **fields: Any) -> None: - result: dict[str, Any] = {"schema_version": RUNTIME_SCHEMA_VERSION, "transition": transition} - if operation_data is not None: - result["operation"] = operation_data - result.update(fields) - print(json.dumps(result, ensure_ascii=False, sort_keys=True)) - - -def git(root: Path, *args: str, check: bool = True) -> str: - result = subprocess.run( - ["git", *args], - cwd=root, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - ) - if check and result.returncode != 0: - raise ContractError(result.stderr.strip() or result.stdout.strip() or "git command failed") - return result.stdout.strip() - - -def repository_root(workspace: str) -> Path: - path = Path(workspace).expanduser().resolve() - if not path.is_dir(): - raise ContractError(f"workspace does not exist: {path}") - root = Path(git(path, "rev-parse", "--show-toplevel")) - if root != path: - raise ContractError(f"workspace must be repository root: {path}") - return root +def capture_runtime_source_envelope( + inputs: SelectedRuntimeSourceInputs, + captures: Iterable[tuple[str, str, bytes]], +) -> dict[str, Any]: + """Capture exact external roots against one proven source selection.""" + + inputs = _require_proven_inputs(inputs) + if isinstance(captures, (str, bytes, bytearray)): + raise RuntimeContractError("external captures must be a sequence") + values = list(captures) + if len(values) != len(inputs.external_roots): + raise RuntimeContractError( + "external captures must contain exactly every proven root" + ) + normalized: list[tuple[str, str, bytes]] = [] + total = 0 + seen: set[tuple[str, str]] = set() + for index, item in enumerate(values): + if not isinstance(item, (tuple, list)) or len(item) != 3: + raise RuntimeContractError( + f"external capture {index} must be (kind, key, bytes)" + ) + kind = _string(item[0], f"external capture {index}.kind") + key = _string(item[1], f"external capture {index}.key") + contents = item[2] + if not isinstance(contents, bytes): + raise RuntimeContractError( + f"external capture {index}.contents must be bytes" + ) + total += len(contents) + if total > MAX_EXTERNAL_TOTAL_BYTES: + raise RuntimeContractError( + "external captures exceed the total byte limit" + ) + identity = (kind, key) + if identity in seen: + raise RuntimeContractError("external captures must be unique") + seen.add(identity) + normalized.append((kind, key, contents)) + payload = _envelope_payload(inputs, normalized) + envelope_bytes = canonical_bytes(payload) + if len(envelope_bytes) > MAX_SOURCE_PREVIEW_BYTES: + raise RuntimeContractError("runtime source envelope is too large") + return { + "runtime_source_envelope": payload, + "runtime_source_envelope_digest": sha256_bytes(envelope_bytes), + "project_commit": inputs.project_commit, + } + + +def revalidate_runtime_source_envelope( + inputs: SelectedRuntimeSourceInputs, + previous: Mapping[str, Any], + captures: Iterable[tuple[str, str, bytes]], +) -> dict[str, Any]: + fresh = capture_runtime_source_envelope(inputs, captures) + if dict(previous) != fresh: + raise RuntimeContractError( + "runtime source envelope changed during revalidation" + ) + return fresh + + +def validate_runtime_source_envelope( + value: Mapping[str, Any], +) -> dict[str, Any]: + data = _closed( + value, + { + "schema", + "project_name", + "repository", + "topology_kind", + "project_commit", + "source_preview_sha256", + "artifact_digests", + "external_roots", + }, + "runtime source envelope", + ) + if set(data) != { + "schema", + "project_name", + "repository", + "topology_kind", + "project_commit", + "source_preview_sha256", + "artifact_digests", + "external_roots", + }: + raise RuntimeContractError("runtime source envelope has missing fields") + if data["schema"] != SOURCE_ENVELOPE_SCHEMA: + raise RuntimeContractError("unsupported runtime source envelope schema") + project_name = _string(data["project_name"], "project_name") + repository = _string(data["repository"], "repository") + if not REPOSITORY_RE.fullmatch(repository): + raise RuntimeContractError("repository has an invalid shape") + topology = _string(data["topology_kind"], "topology_kind") + commit = _commit(data["project_commit"], "project_commit") + _digest(data["source_preview_sha256"], "source_preview_sha256") + artifacts = _object(data["artifact_digests"], "artifact_digests") + required = { + "spec_raw_blob_sha256", + "source_manifest_raw_blob_sha256", + "snapshot_raw_blob_sha256", + } + if set(artifacts) - required - {"builder_raw_blob_sha256"}: + raise RuntimeContractError("artifact_digests has unknown fields") + if not required <= set(artifacts): + raise RuntimeContractError("artifact_digests is missing fields") + for key, digest in artifacts.items(): + _digest(digest, f"artifact_digests.{key}") + roots = data["external_roots"] + if not isinstance(roots, list): + raise RuntimeContractError("external_roots must be an array") + normalized_roots = [] + identities: list[tuple[str, str]] = [] + total_bytes = 0 + for index, value in enumerate(roots): + root = _closed( + value, + {"kind", "key", "byte_count", "sha256"}, + f"external_roots[{index}]", + ) + if set(root) != {"kind", "key", "byte_count", "sha256"}: + raise RuntimeContractError( + f"external_roots[{index}] has missing fields" + ) + kind = _string(root["kind"], f"external_roots[{index}].kind") + key = _string(root["key"], f"external_roots[{index}].key") + identities.append((kind, key)) + byte_count = _integer( + root["byte_count"], + f"external_roots[{index}].byte_count", + ) + total_bytes += byte_count + if total_bytes > MAX_EXTERNAL_TOTAL_BYTES: + raise RuntimeContractError( + "external_roots exceed the total byte limit" + ) + normalized_roots.append( + { + "kind": kind, + "key": key, + "byte_count": byte_count, + "sha256": _digest( + root["sha256"], + f"external_roots[{index}].sha256", + ), + } + ) + if normalized_roots[-1]["byte_count"] > MAX_EXTERNAL_ROOT_BYTES: + raise RuntimeContractError( + f"external_roots[{index}].byte_count exceeds its limit" + ) + if identities != sorted(identities) or len(set(identities)) != len(identities): + raise RuntimeContractError( + "external_roots must be sorted and unique" + ) + result = { + "schema": SOURCE_ENVELOPE_SCHEMA, + "project_name": project_name, + "repository": repository, + "topology_kind": topology, + "project_commit": commit, + "source_preview_sha256": data["source_preview_sha256"], + "artifact_digests": dict(sorted(artifacts.items())), + "external_roots": normalized_roots, + } + if len(canonical_bytes(result)) > MAX_SOURCE_PREVIEW_BYTES: + raise RuntimeContractError("runtime source envelope is too large") + return result + + +def validate_captured_runtime_source_envelope( + value: Mapping[str, Any], +) -> dict[str, Any]: + data = _closed( + value, + { + "runtime_source_envelope", + "runtime_source_envelope_digest", + "project_commit", + }, + "captured runtime source envelope", + ) + if set(data) != { + "runtime_source_envelope", + "runtime_source_envelope_digest", + "project_commit", + }: + raise RuntimeContractError( + "captured runtime source envelope has missing fields" + ) + envelope = validate_runtime_source_envelope( + data["runtime_source_envelope"] + ) + digest = _digest( + data["runtime_source_envelope_digest"], + "runtime_source_envelope_digest", + ) + if digest != canonical_sha256(envelope): + raise RuntimeContractError( + "runtime_source_envelope_digest does not match envelope" + ) + if data["project_commit"] != envelope["project_commit"]: + raise RuntimeContractError( + "captured project_commit does not match envelope" + ) + return { + "runtime_source_envelope": envelope, + "runtime_source_envelope_digest": digest, + "project_commit": envelope["project_commit"], + } + + +def parse_runtime_external_captures(raw: bytes | str) -> tuple[ + tuple[str, str, bytes], ... +]: + if isinstance(raw, str): + raw_bytes = raw.encode("utf-8") + elif isinstance(raw, bytes): + raw_bytes = raw + else: + raise RuntimeContractError("capture input must be bytes or text") + if len(raw_bytes) > MAX_CAPTURE_STDIN_BYTES: + raise RuntimeContractError("runtime capture input exceeds its limit") + def reject_duplicate_keys( + pairs: list[tuple[str, Any]], + ) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, item in pairs: + if key in result: + raise RuntimeContractError( + "runtime capture input contains duplicate object keys" + ) + result[key] = item + return result + + try: + text = raw_bytes.decode("utf-8") + decoder = json.JSONDecoder(object_pairs_hook=reject_duplicate_keys) + value, end = decoder.raw_decode(text) + if end != len(text): + raise RuntimeContractError( + "runtime capture input contains trailing bytes" + ) + except RuntimeContractError: + raise + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise RuntimeContractError( + f"runtime capture input is invalid JSON: {error}" + ) from error + data = _closed(value, {"schema", "roots"}, "runtime capture input") + if set(data) != {"schema", "roots"}: + raise RuntimeContractError("runtime capture input has missing fields") + if data["schema"] != EXTERNAL_CAPTURE_SCHEMA: + raise RuntimeContractError("unsupported runtime capture schema") + roots = data["roots"] + if not isinstance(roots, list): + raise RuntimeContractError("runtime capture roots must be an array") + result = [] + total = 0 + for index, value in enumerate(roots): + item = _closed( + value, + {"kind", "key", "contents_base64"}, + f"runtime capture roots[{index}]", + ) + if set(item) != {"kind", "key", "contents_base64"}: + raise RuntimeContractError( + f"runtime capture roots[{index}] has missing fields" + ) + kind = _string(item["kind"], f"runtime capture roots[{index}].kind") + key = _string(item["key"], f"runtime capture roots[{index}].key") + encoded = item["contents_base64"] + if not isinstance(encoded, str): + raise RuntimeContractError( + f"runtime capture roots[{index}].contents_base64 must be text" + ) + try: + contents = base64.b64decode(encoded, validate=True) + except (ValueError, UnicodeError, base64.binascii.Error) as error: + raise RuntimeContractError( + f"runtime capture roots[{index}] has invalid base64" + ) from error + if base64.b64encode(contents).decode("ascii") != encoded: + raise RuntimeContractError( + f"runtime capture roots[{index}] is not canonical base64" + ) + if len(contents) > MAX_EXTERNAL_ROOT_BYTES: + raise RuntimeContractError( + f"runtime capture roots[{index}] exceeds its byte limit" + ) + total += len(contents) + if total > MAX_EXTERNAL_TOTAL_BYTES: + raise RuntimeContractError( + "runtime capture roots exceed the total byte limit" + ) + result.append((kind, key, contents)) + identities = [(kind, key) for kind, key, _ in result] + if identities != sorted(identities) or len(set(identities)) != len(identities): + raise RuntimeContractError( + "runtime capture roots must be sorted and unique" + ) + return tuple(result) + + +SEAL_REQUEST_KEYS = { + "schema", + "operation_report_digests", + "redaction", + "retention_class", +} + + +def validate_terminal_seal_request(value: Mapping[str, Any]) -> dict[str, Any]: + data = _closed(value, SEAL_REQUEST_KEYS, "terminal seal request") + if set(data) != SEAL_REQUEST_KEYS: + raise RuntimeContractError("terminal seal request has missing fields") + if data["schema"] != TERMINAL_SEAL_REQUEST_SCHEMA: + raise RuntimeContractError("unsupported terminal seal request schema") + reports = data["operation_report_digests"] + if not isinstance(reports, list): + raise RuntimeContractError("operation_report_digests must be an array") + allowed = { + "approval", + "merge", + "publication", + "qualification", + "runtime_source", + } + normalized_reports = [] + kinds = [] + for index, value in enumerate(reports): + item = _closed( + value, + {"kind", "sha256"}, + f"operation_report_digests[{index}]", + ) + if set(item) != {"kind", "sha256"}: + raise RuntimeContractError( + f"operation_report_digests[{index}] has missing fields" + ) + kind = _string(item["kind"], f"operation_report_digests[{index}].kind") + if kind not in allowed: + raise RuntimeContractError( + f"unsupported operation report kind: {kind}" + ) + kinds.append(kind) + normalized_reports.append( + {"kind": kind, "sha256": _digest( + item["sha256"], + f"operation_report_digests[{index}].sha256", + )} + ) + if kinds != sorted(kinds) or len(set(kinds)) != len(kinds): + raise RuntimeContractError( + "operation_report_digests must be sorted and unique by kind" + ) + redaction = _closed( + data["redaction"], + {"status", "report_sha256"}, + "redaction", + ) + if set(redaction) != {"status", "report_sha256"}: + raise RuntimeContractError("redaction has missing fields") + if redaction["status"] != "passed": + raise RuntimeContractError("redaction.status must be passed") + normalized_redaction = { + "status": "passed", + "report_sha256": _digest(redaction["report_sha256"], "report_sha256"), + } + if data["retention_class"] != "cleanup_report_only": + raise RuntimeContractError( + "retention_class must be cleanup_report_only" + ) + return { + "schema": TERMINAL_SEAL_REQUEST_SCHEMA, + "operation_report_digests": normalized_reports, + "redaction": normalized_redaction, + "retention_class": "cleanup_report_only", + } + + +def _event_hash(value: Mapping[str, Any]) -> str: + return canonical_sha256(value) + + +def build_terminal_seal_record( + request: Mapping[str, Any], + *, + sequence: int, + task_short_id: str, + previous_hash: str, +) -> dict[str, Any]: + request = validate_terminal_seal_request(request) + sequence = _integer(sequence, "sequence", minimum=2) + task_short_id = _string(task_short_id, "task_short_id") + if not TASK_RE.fullmatch(task_short_id): + raise RuntimeContractError("task_short_id has an invalid shape") + if previous_hash and not SHA256_RE.fullmatch(previous_hash): + raise RuntimeContractError("previous_hash must be lowercase 64-hex") + unsigned = { + "schema_version": 1, + "record_kind": TERMINAL_SEAL_SCHEMA, + "sequence": sequence, + "task_short_id": task_short_id, + "operation_report_digests": request["operation_report_digests"], + "redaction": request["redaction"], + "retention_class": request["retention_class"], + "previous_hash": previous_hash, + } + return {**unsigned, "event_hash": _event_hash(unsigned)} + + +def validate_terminal_seal_record( + value: Mapping[str, Any], + *, + minimum_sequence: int = 2, +) -> dict[str, Any]: + keys = { + "schema_version", + "record_kind", + "sequence", + "task_short_id", + "operation_report_digests", + "redaction", + "retention_class", + "previous_hash", + "event_hash", + } + data = _closed(value, keys, "terminal seal record") + if set(data) != keys: + raise RuntimeContractError("terminal seal record has missing fields") + if data["schema_version"] != 1 or data["record_kind"] != TERMINAL_SEAL_SCHEMA: + raise RuntimeContractError("invalid terminal seal record identity") + sequence = _integer(data["sequence"], "sequence", minimum=minimum_sequence) + request = validate_terminal_seal_request( + { + "schema": TERMINAL_SEAL_REQUEST_SCHEMA, + "operation_report_digests": data["operation_report_digests"], + "redaction": data["redaction"], + "retention_class": data["retention_class"], + } + ) + task = _string(data["task_short_id"], "task_short_id") + if not TASK_RE.fullmatch(task): + raise RuntimeContractError("task_short_id has an invalid shape") + previous = _string( + data["previous_hash"], + "previous_hash", + nonempty=False, + max_bytes=64, + ) + if previous and not SHA256_RE.fullmatch(previous): + raise RuntimeContractError("previous_hash must be lowercase 64-hex") + event_hash = _digest(data["event_hash"], "event_hash") + unsigned = { + "schema_version": 1, + "record_kind": TERMINAL_SEAL_SCHEMA, + "sequence": sequence, + "task_short_id": task, + **{ + "operation_report_digests": request["operation_report_digests"], + "redaction": request["redaction"], + "retention_class": request["retention_class"], + "previous_hash": previous, + }, + } + if _event_hash(unsigned) != event_hash: + raise RuntimeContractError("terminal seal event_hash is invalid") + return {**unsigned, "event_hash": event_hash} + + +def build_terminal_marker( + seal_record: Mapping[str, Any], +) -> dict[str, Any]: + seal = validate_terminal_seal_record(seal_record) + return { + "schema": TERMINAL_MARKER_SCHEMA, + "task_short_id": seal["task_short_id"], + "event_count": seal["sequence"], + "final_hash": seal["event_hash"], + "operation_report_digests": seal["operation_report_digests"], + "redaction": seal["redaction"], + "retention_class": seal["retention_class"], + } + + +def validate_terminal_marker(value: Mapping[str, Any]) -> dict[str, Any]: + keys = { + "schema", + "task_short_id", + "event_count", + "final_hash", + "operation_report_digests", + "redaction", + "retention_class", + } + data = _closed(value, keys, "terminal marker") + if set(data) != keys: + raise RuntimeContractError("terminal marker has missing fields") + if data["schema"] != TERMINAL_MARKER_SCHEMA: + raise RuntimeContractError("unsupported terminal marker schema") + task = _string(data["task_short_id"], "task_short_id") + if not TASK_RE.fullmatch(task): + raise RuntimeContractError("task_short_id has an invalid shape") + count = _integer(data["event_count"], "event_count", minimum=2) + final_hash = _digest(data["final_hash"], "final_hash") + request = validate_terminal_seal_request( + { + "schema": TERMINAL_SEAL_REQUEST_SCHEMA, + "operation_report_digests": data["operation_report_digests"], + "redaction": data["redaction"], + "retention_class": data["retention_class"], + } + ) + return { + "schema": TERMINAL_MARKER_SCHEMA, + "task_short_id": task, + "event_count": count, + "final_hash": final_hash, + "operation_report_digests": request["operation_report_digests"], + "redaction": request["redaction"], + "retention_class": request["retention_class"], + } + + +def terminal_marker_line(marker: Mapping[str, Any]) -> str: + return "TERMINAL_EVIDENCE_V1 " + json.dumps( + validate_terminal_marker(marker), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def parse_terminal_marker_line(line: str) -> dict[str, Any]: + if not isinstance(line, str) or not line.startswith( + "TERMINAL_EVIDENCE_V1 " + ): + raise RuntimeContractError("invalid terminal marker line") + suffix = line.removeprefix("TERMINAL_EVIDENCE_V1 ") + if not suffix or suffix != suffix.strip(): + raise RuntimeContractError("terminal marker line is not canonical") + try: + value = json.loads(suffix) + except json.JSONDecodeError as error: + raise RuntimeContractError("terminal marker line is invalid JSON") from error + marker = validate_terminal_marker(value) + if terminal_marker_line(marker) != line: + raise RuntimeContractError("terminal marker line is not canonical") + return marker + + +def validate_terminal_chain( + records: Sequence[Mapping[str, Any]], + *, + task_short_id: str, +) -> dict[str, Any]: + if not records: + raise RuntimeContractError("terminal chain must not be empty") + previous = "" + for index, raw in enumerate(records, start=1): + data = _object(raw, f"ledger record {index}") + if data.get("schema_version") != 1: + raise RuntimeContractError("ledger record has unsupported schema") + if data.get("sequence") != index: + raise RuntimeContractError("ledger sequence is not contiguous") + if data.get("task_short_id") != task_short_id: + raise RuntimeContractError("ledger task does not match") + if data.get("previous_hash") != previous: + raise RuntimeContractError("ledger hash chain is broken") + event_hash = _digest(data.get("event_hash"), "event_hash") + unsigned = dict(data) + del unsigned["event_hash"] + if _event_hash(unsigned) != event_hash: + raise RuntimeContractError("ledger event hash is invalid") + if ( + index < len(records) + and data.get("record_kind") == TERMINAL_SEAL_SCHEMA + ): + raise RuntimeContractError("terminal seal must be the final record") + previous = event_hash + seal = validate_terminal_seal_record(records[-1]) + if seal["sequence"] != len(records) or seal["previous_hash"] != ( + records[-2]["event_hash"] + ): + raise RuntimeContractError("terminal seal is not the final chain record") + return build_terminal_marker(seal) + + +def validate_cleanup_report(report: str) -> dict[str, Any]: + if not isinstance(report, str): + raise RuntimeContractError("cleanup_report must be text") + lines = report.splitlines() + if not lines: + raise RuntimeContractError("cleanup_report must contain a marker") + marker_indices = [ + index for index, line in enumerate(lines) + if line.startswith("TERMINAL_EVIDENCE_V1 ") + ] + if marker_indices != [len(lines) - 1]: + raise RuntimeContractError( + "cleanup_report must end with exactly one terminal marker" + ) + return parse_terminal_marker_line(lines[-1]) + + +def classify_terminal_state( + *, + active: bool, + tombstone: bool, + sentinel: bool, + ledger_valid: bool, + marker_valid: bool, + managed_worktree: bool = False, + workspace_present: bool = True, + git_registered: bool = True, + same_invocation: bool = False, + tombstone_entries: Sequence[str] = (), +) -> str: + values = ( + active, + tombstone, + sentinel, + ledger_valid, + marker_valid, + managed_worktree, + workspace_present, + git_registered, + same_invocation, + ) + if any(not isinstance(value, bool) for value in values): + raise RuntimeContractError("terminal state flags must be booleans") + if not isinstance(tombstone_entries, (tuple, list)): + raise RuntimeContractError("tombstone_entries must be a sequence") + entries = tuple( + _string(item, "tombstone_entries[]", max_bytes=256) + for item in tombstone_entries + ) + allowed_entries = ("fix-checkpoint.json", "smoke-checkpoint.json") + if entries != tuple( + item for item in allowed_entries if item in entries + ): + return "blocked_unknown_tombstone_entry" + if not tombstone and entries: + return "blocked_unknown_tombstone_entry" + if active and tombstone: + return "blocked_conflicting_task_state" + if active and sentinel: + return "blocked_active_terminal_conflict" + if tombstone and not marker_valid: + return "blocked_marker_invalid" + if tombstone and not ledger_valid and not sentinel: + return "blocked_missing_valid_ledger" + if tombstone and not sentinel and ledger_valid and marker_valid: + return "pre_sentinel_recovery" + if sentinel and tombstone and ledger_valid and marker_valid: + return "resume_tombstone_cleanup" + if ( + sentinel + and tombstone + and not ledger_valid + and marker_valid + and not entries + ): + return "resume_tombstone_cleanup" + if sentinel and tombstone and not ledger_valid and marker_valid: + return "blocked_checkpoint_after_ledger_loss" + if sentinel and not active and not tombstone and marker_valid: + return "acknowledgement_loss" + if managed_worktree and same_invocation and not workspace_present and not git_registered: + return "acknowledgement_loss" + if managed_worktree and not workspace_present and not same_invocation: + return "blocked_managed_workspace_evidence_loss" + if managed_worktree and workspace_present and not tombstone: + return "blocked_managed_workspace_evidence_loss" + if not active and not tombstone and not sentinel: + return "no_runtime_state" + return "blocked_terminal_state" + + +VERIFICATION_CODES = { + "passed", + "verification_failed", + "verification_blocked", + "input_invalid", + "workspace_invalid", + "verifier_missing", + "verifier_unsafe", + "log_path_unsafe", + "child_timeout", + "child_exit_nonzero", + "child_output_invalid", + "log_limit_exceeded", + "internal_error", +} + + +def validate_verification_report(value: Mapping[str, Any]) -> dict[str, Any]: + keys = {"schema", "code", "log_path", "log_sha256", "exit_code"} + data = _closed(value, keys, "verification report") + if set(data) != keys: + raise RuntimeContractError("verification report has missing fields") + if data["schema"] != VERIFICATION_REPORT_SCHEMA: + raise RuntimeContractError("unsupported verification report schema") + code = _string(data["code"], "verification report.code") + if code not in VERIFICATION_CODES: + raise RuntimeContractError("verification report.code is unsupported") + path = data["log_path"] + if path is not None: + path = _string(path, "verification report.log_path") + if not LOG_PATH_RE.fullmatch(path): + raise RuntimeContractError("verification report.log_path is unsafe") + digest = data["log_sha256"] + if digest is not None: + digest = _digest(digest, "verification report.log_sha256") + exit_code = data["exit_code"] + if exit_code is not None: + if not isinstance(exit_code, int) or isinstance(exit_code, bool): + raise RuntimeContractError("verification report.exit_code is invalid") + if not -255 <= exit_code <= 255: + raise RuntimeContractError("verification report.exit_code is out of range") + if code == "passed" and (path is None or digest is None or exit_code != 0): + raise RuntimeContractError("passed verification requires a committed log") + return { + "schema": VERIFICATION_REPORT_SCHEMA, + "code": code, + "log_path": path, + "log_sha256": digest, + "exit_code": exit_code, + } + + +def classify_verification_report( + value: Mapping[str, Any], +) -> str: + code = validate_verification_report(value)["code"] + if code == "passed": + return "passed" + if code in {"verification_failed", "child_exit_nonzero"}: + return "needs_changes" + return "blocked" + + +def classify_ci_report(value: Mapping[str, Any]) -> str: + report = validate_ci_report(value) + reason = report["attempts"][-1]["reason"] + if reason == "all_expected_checks_terminal_green": + return "ci_watch_passed" + if reason in {"expected_check_failed", "expected_check_skipped"}: + return "ci_watch_failed" + if reason in { + "github_query_failed", + "duplicate_observed_check", + "expected_contract_invalid", + "report_invalid", + }: + return "ci_watch_blocked" + return "ci_watch_waiting" + + +def _check_name(value: Any, label: str) -> str: + return _string(value, label, max_bytes=256) + + +def _validate_observed_check( + value: Mapping[str, Any], + label: str, +) -> dict[str, Any]: + keys = {"workflow_name", "check_name", "bucket", "state", "link"} + data = _closed(value, keys, label) + if set(data) != keys: + raise RuntimeContractError(f"{label} has missing fields") + workflow = _check_name(data["workflow_name"], f"{label}.workflow_name") + check = _check_name(data["check_name"], f"{label}.check_name") + bucket = _string(data["bucket"], f"{label}.bucket") + if bucket not in {"pass", "fail", "pending", "skipping", "cancel"}: + raise RuntimeContractError(f"{label}.bucket is unsupported") + state = _string(data["state"], f"{label}.state", max_bytes=64) + if not re.fullmatch(r"[A-Z_]{1,64}", state): + raise RuntimeContractError(f"{label}.state is invalid") + link = data["link"] + if link is not None: + link = _string(link, f"{label}.link", max_bytes=2048) + if not LINK_RE.match(link): + raise RuntimeContractError(f"{label}.link is unsafe") + return { + "workflow_name": workflow, + "check_name": check, + "bucket": bucket, + "state": state, + "link": link, + } + + +def validate_pr_feedback_item(value: Mapping[str, Any]) -> dict[str, Any]: + data = _object(value, "feedback item") + kind = data.get("kind") + if kind == "issue_comment": + keys = { + "kind", + "id", + "author_login", + "created_at", + "updated_at", + "body_bytes", + "body_sha256", + } + item = _closed(data, keys, "issue_comment") + if set(item) != keys: + raise RuntimeContractError("issue_comment has missing fields") + return { + "kind": kind, + "id": _string(item["id"], "issue_comment.id", max_bytes=256), + "author_login": ( + None + if item["author_login"] is None + else _string(item["author_login"], "issue_comment.author_login", max_bytes=100) + ), + "created_at": _timestamp(item["created_at"], "issue_comment.created_at"), + "updated_at": _timestamp(item["updated_at"], "issue_comment.updated_at"), + "body_bytes": _integer(item["body_bytes"], "issue_comment.body_bytes"), + "body_sha256": _digest(item["body_sha256"], "issue_comment.body_sha256"), + } + if kind == "review": + keys = { + "kind", + "id", + "author_login", + "state", + "submitted_at", + "updated_at", + "commit_oid", + "body_bytes", + "body_sha256", + } + item = _closed(data, keys, "review") + if set(item) != keys: + raise RuntimeContractError("review has missing fields") + return { + "kind": kind, + "id": _string(item["id"], "review.id", max_bytes=256), + "author_login": _optional_login(item["author_login"], "review.author_login"), + "state": _upper_state(item["state"], "review.state"), + "submitted_at": _optional_timestamp(item["submitted_at"], "review.submitted_at"), + "updated_at": _timestamp(item["updated_at"], "review.updated_at"), + "commit_oid": _commit(item["commit_oid"], "review.commit_oid", nullable=True), + "body_bytes": _integer(item["body_bytes"], "review.body_bytes"), + "body_sha256": _digest(item["body_sha256"], "review.body_sha256"), + } + if kind == "review_thread": + keys = { + "kind", + "id", + "resolved", + "outdated", + "path", + "current_line", + "current_start_line", + "original_line", + "original_start_line", + "subject_type", + "comment_ids", + } + item = _closed(data, keys, "review_thread") + if set(item) != keys: + raise RuntimeContractError("review_thread has missing fields") + comments = _sorted_unique_strings( + item["comment_ids"], + "review_thread.comment_ids", + max_items=MAX_FEEDBACK_HARD_LIMIT, + ) + if any(len(comment_id.encode("utf-8")) > 256 for comment_id in comments): + raise RuntimeContractError( + "review_thread.comment_ids[] exceeds the 256-byte limit" + ) + return { + "kind": kind, + "id": _string(item["id"], "review_thread.id", max_bytes=256), + "resolved": _boolean(item["resolved"], "review_thread.resolved"), + "outdated": _boolean(item["outdated"], "review_thread.outdated"), + "path": _string(item["path"], "review_thread.path", max_bytes=1024), + "current_line": _line(item["current_line"], "review_thread.current_line"), + "current_start_line": _line( + item["current_start_line"], + "review_thread.current_start_line", + ), + "original_line": _line(item["original_line"], "review_thread.original_line"), + "original_start_line": _line( + item["original_start_line"], + "review_thread.original_start_line", + ), + "subject_type": _string(item["subject_type"], "review_thread.subject_type", max_bytes=64), + "comment_ids": list(comments), + } + if kind == "review_comment": + keys = { + "kind", + "id", + "thread_id", + "author_login", + "created_at", + "updated_at", + "current_commit_oid", + "original_commit_oid", + "body_bytes", + "body_sha256", + } + item = _closed(data, keys, "review_comment") + if set(item) != keys: + raise RuntimeContractError("review_comment has missing fields") + return { + "kind": kind, + "id": _string(item["id"], "review_comment.id", max_bytes=256), + "thread_id": _string(item["thread_id"], "review_comment.thread_id", max_bytes=256), + "author_login": _optional_login(item["author_login"], "review_comment.author_login"), + "created_at": _timestamp(item["created_at"], "review_comment.created_at"), + "updated_at": _timestamp(item["updated_at"], "review_comment.updated_at"), + "current_commit_oid": _commit( + item["current_commit_oid"], + "review_comment.current_commit_oid", + nullable=True, + ), + "original_commit_oid": _commit( + item["original_commit_oid"], + "review_comment.original_commit_oid", + nullable=True, + ), + "body_bytes": _integer(item["body_bytes"], "review_comment.body_bytes"), + "body_sha256": _digest(item["body_sha256"], "review_comment.body_sha256"), + } + raise RuntimeContractError("feedback item kind is unsupported") + + +def check_state_sha256(checks: Sequence[Mapping[str, Any]]) -> str: + if not isinstance(checks, Sequence) or isinstance(checks, (str, bytes)): + raise RuntimeContractError("check state rows must be a sequence") + if len(checks) > 1000: + raise RuntimeContractError("check state rows exceed their hard limit") + rows = [] + for index, value in enumerate(checks): + data = _object(value, f"check_state[{index}]") + allowed = {"name", "context", "status", "state", "conclusion"} + unknown = sorted(set(data) - allowed) + if unknown: + raise RuntimeContractError( + f"check_state[{index}] has unknown fields: {unknown}" + ) + identity_keys = [ + key for key in ("name", "context") + if key in data and data[key] is not None + ] + state_keys = [ + key for key in ("status", "state") + if key in data and data[key] is not None + ] + if len(identity_keys) != 1 or len(state_keys) != 1: + raise RuntimeContractError( + f"check_state[{index}] needs exactly one non-null " + "name/context and status/state" + ) + rows.append( + [ + _string( + data[identity_keys[0]], + f"check_state[{index}].{identity_keys[0]}", + max_bytes=256, + ), + _string( + data[state_keys[0]], + f"check_state[{index}].{state_keys[0]}", + max_bytes=64, + ), + _string( + "" if data.get("conclusion") is None else data["conclusion"], + f"check_state[{index}].conclusion", + nonempty=False, + max_bytes=64, + ), + ] + ) + rows.sort() + return canonical_sha256(rows) + + +def _timestamp(value: Any, label: str) -> str: + value = _string(value, label, max_bytes=40) + if not RFC3339_RE.fullmatch(value): + raise RuntimeContractError(f"{label} is not RFC3339") + return value + + +def _optional_timestamp(value: Any, label: str) -> str | None: + return None if value is None else _timestamp(value, label) + + +def _optional_login(value: Any, label: str) -> str | None: + return None if value is None else _string(value, label, max_bytes=100) + + +def _upper_state(value: Any, label: str) -> str: + value = _string(value, label, max_bytes=64) + if not UPPER_TOKEN_RE.fullmatch(value): + raise RuntimeContractError(f"{label} is invalid") + return value + + +def _line(value: Any, label: str) -> int | None: + if value is None: + return None + return _integer(value, label, minimum=1) + + +def _timestamp_or_empty(value: Any, label: str) -> str: + if value == "": + return "" + return _upper_state(value, label) + + +def validate_pr_feedback_cursor(value: Any) -> str | dict[str, Any]: + if value == "uninitialized": + return value + data = _object(value, "PR feedback cursor") + common = { + "schema", + "mode", + "repository", + "pull_number", + "head_oid", + "base_oid", + "pr_state", + "review_decision", + "merge_state_status", + "check_count", + "checks_sha256", + "item_count", + "items_sha256", + } + mode = data.get("mode") + keys = common | ({"items"} if mode == "complete" else set()) + data = _closed(data, keys, "PR feedback cursor") + if set(data) != keys: + raise RuntimeContractError("PR feedback cursor has missing fields") + if data["schema"] != PR_CURSOR_SCHEMA: + raise RuntimeContractError("unsupported PR feedback cursor schema") + if mode not in {"complete", "digest_only"}: + raise RuntimeContractError("PR feedback cursor.mode is unsupported") + repository = _string(data["repository"], "cursor.repository") + if not REPOSITORY_RE.fullmatch(repository): + raise RuntimeContractError("cursor.repository has an invalid shape") + pull = _integer(data["pull_number"], "cursor.pull_number", minimum=1) + if pull > 2147483647: + raise RuntimeContractError("cursor.pull_number is too large") + head = _commit(data["head_oid"], "cursor.head_oid") + base = _commit(data["base_oid"], "cursor.base_oid") + pr_state = _upper_state(data["pr_state"], "cursor.pr_state") + review = _timestamp_or_empty(data["review_decision"], "cursor.review_decision") + merge = _timestamp_or_empty( + data["merge_state_status"], + "cursor.merge_state_status", + ) + check_count = _integer(data["check_count"], "cursor.check_count") + if check_count > MAX_FEEDBACK_HARD_LIMIT: + raise RuntimeContractError("cursor.check_count exceeds its hard limit") + checks_sha = _digest(data["checks_sha256"], "cursor.checks_sha256") + item_count = _integer(data["item_count"], "cursor.item_count") + if item_count > MAX_FEEDBACK_HARD_LIMIT: + raise RuntimeContractError("cursor.item_count exceeds its hard limit") + items_sha = _digest(data["items_sha256"], "cursor.items_sha256") + result = { + "schema": PR_CURSOR_SCHEMA, + "mode": mode, + "repository": repository, + "pull_number": pull, + "head_oid": head, + "base_oid": base, + "pr_state": pr_state, + "review_decision": review, + "merge_state_status": merge, + "check_count": check_count, + "checks_sha256": checks_sha, + "item_count": item_count, + "items_sha256": items_sha, + } + if mode == "complete": + items = data["items"] + if not isinstance(items, list) or len(items) > MAX_FEEDBACK_ITEMS: + raise RuntimeContractError("complete cursor items exceed its limit") + normalized = [validate_pr_feedback_item(item) for item in items] + identities = [(item["kind"], item["id"]) for item in normalized] + if identities != sorted(identities) or len(set(identities)) != len(identities): + raise RuntimeContractError("cursor items must be sorted and unique") + if canonical_sha256(normalized) != items_sha: + raise RuntimeContractError("cursor items_sha256 is invalid") + if item_count != len(normalized): + raise RuntimeContractError( + "complete cursor item_count does not match items" + ) + result["items"] = normalized + if len(canonical_bytes(result)) > MAX_FEEDBACK_BYTES: + raise RuntimeContractError("PR feedback cursor exceeds its byte limit") + return result + + +def make_pr_feedback_cursor( + *, + repository: str, + pull_number: int, + head_oid: str, + base_oid: str, + pr_state: str, + review_decision: str, + merge_state_status: str, + checks: Sequence[Mapping[str, Any]], + items: Sequence[Mapping[str, Any]], +) -> str | dict[str, Any]: + normalized_checks = [ + _validate_observed_check(item, f"checks[{index}]") + for index, item in enumerate(checks) + ] + if len(normalized_checks) > 1000: + raise RuntimeContractError("cursor checks exceed their hard limit") + normalized_checks.sort( + key=lambda item: ( + item["workflow_name"], + item["check_name"], + item["state"], + ) + ) + normalized_items = [ + validate_pr_feedback_item(item) for item in items + ] + if len(normalized_items) > MAX_FEEDBACK_HARD_LIMIT: + raise RuntimeContractError("feedback item hard limit exceeded") + identities = [(item["kind"], item["id"]) for item in normalized_items] + if identities != sorted(identities) or len(set(identities)) != len(identities): + raise RuntimeContractError("cursor items must be sorted and unique") + item_digest = canonical_sha256(normalized_items) + check_digest = check_state_sha256( + [ + { + "name": ( + f"{item['workflow_name']}::{item['check_name']}" + ), + "status": item["state"], + "conclusion": item["bucket"], + } + for item in normalized_checks + ] + ) + mode = "complete" if len(normalized_items) <= MAX_FEEDBACK_ITEMS else "digest_only" + result: dict[str, Any] = { + "schema": PR_CURSOR_SCHEMA, + "mode": mode, + "repository": repository, + "pull_number": pull_number, + "head_oid": head_oid, + "base_oid": base_oid, + "pr_state": pr_state, + "review_decision": review_decision, + "merge_state_status": merge_state_status, + "check_count": len(normalized_checks), + "checks_sha256": check_digest, + "item_count": len(normalized_items), + "items_sha256": item_digest, + } + if mode == "complete": + result["items"] = normalized_items + if len(canonical_bytes(result)) > MAX_FEEDBACK_BYTES: + result.pop("items") + result["mode"] = "digest_only" + return validate_pr_feedback_cursor(result) + + +def classify_pr_feedback( + previous: Any, + current: Mapping[str, Any] | str, +) -> dict[str, Any]: + current = validate_pr_feedback_cursor(current) + if current == "uninitialized": + raise RuntimeContractError("current cursor must be materialized") + previous = validate_pr_feedback_cursor(previous) + if current["item_count"] > MAX_FEEDBACK_HARD_LIMIT: + return {"transition": "feedback_hard_limit", "cursor": current} + if previous == "uninitialized": + transition = ( + "state_changed" if current["item_count"] else "still_waiting" + ) + else: + transition = ( + "still_waiting" if previous == current else "state_changed" + ) + return {"transition": transition, "cursor": current} + + +def validate_expected_ci_checks(value: Mapping[str, Any]) -> dict[str, Any]: + keys = { + "schema", + "repository", + "project_commit", + "runtime_source_envelope_digest", + "checks", + } + data = _closed(value, keys, "expected CI checks") + if set(data) != keys: + raise RuntimeContractError("expected CI checks has missing fields") + if data["schema"] != EXPECTED_CHECKS_SCHEMA: + raise RuntimeContractError("unsupported expected CI checks schema") + repository = _string(data["repository"], "expected.repository") + if not REPOSITORY_RE.fullmatch(repository): + raise RuntimeContractError("expected.repository has an invalid shape") + commit = _commit(data["project_commit"], "expected.project_commit") + envelope = _digest( + data["runtime_source_envelope_digest"], + "expected.runtime_source_envelope_digest", + ) + checks = data["checks"] + if not isinstance(checks, list) or not checks: + raise RuntimeContractError("expected checks must be non-empty") + if len(checks) > MAX_EXPECTED_CHECKS: + raise RuntimeContractError("expected checks exceed their limit") + normalized = [] + identities = [] + for index, value in enumerate(checks): + item = _closed( + value, + {"workflow_name", "check_name", "allow_skipped"}, + f"expected.checks[{index}]", + ) + if set(item) != {"workflow_name", "check_name", "allow_skipped"}: + raise RuntimeContractError( + f"expected.checks[{index}] has missing fields" + ) + workflow = _check_name( + item["workflow_name"], + f"expected.checks[{index}].workflow_name", + ) + name = _check_name( + item["check_name"], + f"expected.checks[{index}].check_name", + ) + identity = (workflow, name) + identities.append(identity) + normalized.append( + { + "workflow_name": workflow, + "check_name": name, + "allow_skipped": _boolean( + item["allow_skipped"], + f"expected.checks[{index}].allow_skipped", + ), + } + ) + if identities != sorted(identities) or len(set(identities)) != len(identities): + raise RuntimeContractError("expected checks must be sorted and unique") + result = { + "schema": EXPECTED_CHECKS_SCHEMA, + "repository": repository, + "project_commit": commit, + "runtime_source_envelope_digest": envelope, + "checks": normalized, + } + if len(canonical_bytes(result)) > MAX_EXPECTED_CHECKS_BYTES: + raise RuntimeContractError("expected CI checks exceed their byte limit") + return result + + +def expected_ci_checks_sha256(value: Mapping[str, Any]) -> str: + return canonical_sha256(validate_expected_ci_checks(value)) + + +def _observed_check_sort_key(value: Mapping[str, Any]) -> tuple[str, ...]: + return ( + value["workflow_name"], + value["check_name"], + value["bucket"], + value["state"], + "" if value["link"] is None else value["link"], + ) + + +def _expected_check_observation_metadata( + normalized: Sequence[Mapping[str, Any]], + expected_by_identity: Mapping[tuple[str, str], Mapping[str, Any]], +) -> dict[str, Any]: + ordered = sorted(normalized, key=_observed_check_sort_key) + expected_rows = [ + item + for item in ordered + if (item["workflow_name"], item["check_name"]) + in expected_by_identity + ] + unexpected_rows = [ + item + for item in ordered + if (item["workflow_name"], item["check_name"]) + not in expected_by_identity + ] + return { + "expected_checks": expected_rows, + "unexpected_check_count": len(unexpected_rows), + "unexpected_checks_sha256": canonical_sha256(unexpected_rows), + } + + +def _expected_check_result( + transition: str, + metadata: Mapping[str, Any], + **extra: Any, +) -> dict[str, Any]: + result = {"transition": transition} + result.update(metadata) + result.update(extra) + return result + + +def _empty_expected_check_observation_metadata() -> dict[str, Any]: + return { + "expected_checks": [], + "unexpected_check_count": 0, + "unexpected_checks_sha256": canonical_sha256([]), + } + + +def _project_observed_rows(observed: Sequence[Any]) -> list[dict[str, Any]]: + fields = ("workflow_name", "check_name", "bucket", "state", "link") + projected = [] + for item in observed: + if isinstance(item, Mapping): + projected.append({field: item.get(field) for field in fields}) + else: + projected.append({field: None for field in fields}) + return projected + + +def _observation_limit_value( + receipt: RejectedObservationReceipt, +) -> dict[str, Any]: + return _expected_check_result( + "report_invalid", + { + "expected_checks": [], + "unexpected_check_count": receipt.count, + "unexpected_checks_sha256": receipt.sha256, + }, + reason="observation_limit", + ) + + +def classify_expected_ci_checks_with_receipt( + expected: Mapping[str, Any], + observed: Sequence[Mapping[str, Any]], + *, + current_repository: str, + current_head_oid: str, + runtime_source_envelope_digest: str, + expected_checks_digest: str, +) -> ExpectedCiClassification: + expected = validate_expected_ci_checks(expected) + current_repository = _string(current_repository, "current_repository") + if not REPOSITORY_RE.fullmatch(current_repository): + raise RuntimeContractError("current_repository has an invalid shape") + current_head_oid = _commit(current_head_oid, "current_head_oid") + runtime_source_envelope_digest = _digest( + runtime_source_envelope_digest, + "runtime_source_envelope_digest", + ) + expected_checks_digest = _digest( + expected_checks_digest, + "expected_checks_sha256", + ) + empty_metadata = _empty_expected_check_observation_metadata() + expected_object_digest = expected_ci_checks_sha256(expected) + if expected["repository"] != current_repository: + return ExpectedCiClassification( + "ordinary", + _expected_check_result("expected_contract_invalid", empty_metadata), + None, + None, + ) + if expected["project_commit"] != current_head_oid: + return ExpectedCiClassification( + "ordinary", + _expected_check_result("source_changed", empty_metadata), + None, + None, + ) + if ( + expected["runtime_source_envelope_digest"] + != runtime_source_envelope_digest + ): + return ExpectedCiClassification( + "ordinary", + _expected_check_result("expected_contract_invalid", empty_metadata), + None, + None, + ) + if expected_object_digest != expected_checks_digest: + return ExpectedCiClassification( + "ordinary", + _expected_check_result("expected_contract_invalid", empty_metadata), + None, + None, + ) + expected_by_identity = { + (item["workflow_name"], item["check_name"]): item + for item in expected["checks"] + } + if not isinstance(observed, Sequence) or isinstance(observed, (str, bytes)): + raise RuntimeContractError("observed checks must be a sequence") + projected = _project_observed_rows(observed) + projected_receipt = _bounded_canonical_observation( + projected, + "projected_rows", + ) + if isinstance(projected_receipt, RejectedObservationHardLimit): + return ExpectedCiClassification( + "hard_limit", + None, + projected_receipt, + None, + ) + try: + normalized = [ + _validate_observed_check(item, f"observed[{index}]") + for index, item in enumerate(observed) + ] + except RuntimeContractError as error: + return ExpectedCiClassification( + "grammar_invalid", + None, + projected_receipt, + None, + error, + ) + ordered = sorted(normalized, key=_observed_check_sort_key) + expected_rows = [ + item + for item in ordered + if (item["workflow_name"], item["check_name"]) in expected_by_identity + ] + unexpected_rows = [ + item + for item in ordered + if (item["workflow_name"], item["check_name"]) not in expected_by_identity + ] + unexpected_receipt = _bounded_canonical_observation( + unexpected_rows, + "unexpected_rows", + ) + if isinstance(unexpected_receipt, RejectedObservationHardLimit): + return ExpectedCiClassification( + "hard_limit", + None, + projected_receipt, + unexpected_receipt, + ) + metadata = { + "expected_checks": expected_rows, + "unexpected_check_count": unexpected_receipt.count, + "unexpected_checks_sha256": unexpected_receipt.sha256, + } + identities = [ + (item["workflow_name"], item["check_name"]) for item in normalized + ] + if unexpected_receipt.count > MAX_CI_UNEXPECTED_OBSERVATIONS: + return ExpectedCiClassification( + "observation_limit", + _observation_limit_value(unexpected_receipt), + projected_receipt, + unexpected_receipt, + ) + if len(identities) != len(set(identities)): + return ExpectedCiClassification( + "ordinary", + _expected_check_result("duplicate_observed_check", metadata), + projected_receipt, + None, + ) + observed_by_identity = dict(zip(identities, normalized)) + if not observed: + value = _expected_check_result("no_checks_reported", metadata) + return ExpectedCiClassification("ordinary", value, projected_receipt, None) + missing = sorted(set(expected_by_identity) - set(observed_by_identity)) + if missing: + value = _expected_check_result( + "expected_check_missing", metadata, missing=missing + ) + return ExpectedCiClassification("ordinary", value, projected_receipt, None) + for identity, item in observed_by_identity.items(): + if identity not in expected_by_identity: + continue + if item["bucket"] == "fail" or item["bucket"] == "cancel": + value = _expected_check_result( + "expected_check_failed", metadata, check=identity + ) + return ExpectedCiClassification("ordinary", value, projected_receipt, None) + if item["bucket"] == "skipping" and not expected_by_identity[identity]["allow_skipped"]: + value = _expected_check_result( + "expected_check_skipped", metadata, check=identity + ) + return ExpectedCiClassification("ordinary", value, projected_receipt, None) + if item["bucket"] == "pending": + value = _expected_check_result( + "pending_limit", metadata, check=identity + ) + return ExpectedCiClassification("ordinary", value, projected_receipt, None) + value = _expected_check_result( + "all_expected_checks_terminal_green", metadata + ) + return ExpectedCiClassification("ordinary", value, projected_receipt, None) + + +def classify_expected_ci_checks( + expected: Mapping[str, Any], + observed: Sequence[Mapping[str, Any]], + *, + current_repository: str, + current_head_oid: str, + runtime_source_envelope_digest: str, + expected_checks_digest: str, +) -> dict[str, Any]: + classification = classify_expected_ci_checks_with_receipt( + expected, + observed, + current_repository=current_repository, + current_head_oid=current_head_oid, + runtime_source_envelope_digest=runtime_source_envelope_digest, + expected_checks_digest=expected_checks_digest, + ) + if classification.state == "grammar_invalid": + if classification.grammar_error is not None: + raise classification.grammar_error + raise RuntimeContractError("observed checks have invalid grammar") + if classification.state == "hard_limit": + raise RuntimeContractError("observations exceed their canonical byte limit") + if classification.value is None: + raise RuntimeContractError("classification has no ordinary value") + return classification.materialize_value() + + +CI_REPORT_REASONS = { + "all_expected_checks_terminal_green", + "expected_check_missing", + "expected_check_failed", + "expected_check_skipped", + "pending_limit", + "duplicate_observed_check", + "no_checks_reported", + "github_query_failed", + "expected_contract_invalid", + "report_invalid", +} +CI_ERROR_CODES = { + "github_query_failed", + "github_output_invalid", + "expected_contract_invalid", + "report_invalid", + "observation_limit", + "hard_limit", +} + + +def _validate_ci_attempt(value: Mapping[str, Any], label: str) -> dict[str, Any]: + keys = { + "sequence", + "head_oid", + "base_oid", + "reason", + "watcher_exit_code", + "expected_checks", + "unexpected_check_count", + "unexpected_checks_sha256", + "retry", + "safe_error", + } + data = _closed(value, keys, label) + if set(data) != keys: + raise RuntimeContractError(f"{label} has missing fields") + sequence = _integer(data["sequence"], f"{label}.sequence", minimum=1) + if sequence > 2147483647: + raise RuntimeContractError(f"{label}.sequence is too large") + reason = _string(data["reason"], f"{label}.reason") + if reason not in CI_REPORT_REASONS: + raise RuntimeContractError(f"{label}.reason is unsupported") + watcher_exit = data["watcher_exit_code"] + if watcher_exit is not None: + watcher_exit = _integer( + watcher_exit, + f"{label}.watcher_exit_code", + minimum=-255, + ) + if watcher_exit > 255: + raise RuntimeContractError(f"{label}.watcher_exit_code is too large") + observations = data["expected_checks"] + if not isinstance(observations, list): + raise RuntimeContractError(f"{label}.expected_checks must be an array") + raw_safe_error = data["safe_error"] + raw_error_code = ( + raw_safe_error.get("code") + if isinstance(raw_safe_error, Mapping) + else None + ) + unexpected_count = _integer( + data["unexpected_check_count"], + f"{label}.unexpected_check_count", + ) + exceptional_observation_attempt = ( + raw_error_code == "observation_limit" + and unexpected_count > MAX_CI_UNEXPECTED_OBSERVATIONS + ) + hard_limit_attempt = ( + reason == "report_invalid" and raw_error_code == "hard_limit" + ) + if ( + (exceptional_observation_attempt or hard_limit_attempt) + and reason != "report_invalid" + ): + raise RuntimeContractError( + f"{label}.bounded observation errors require report_invalid" + ) + special_observation_attempt = ( + reason == "report_invalid" + and (exceptional_observation_attempt or hard_limit_attempt) + ) + if special_observation_attempt and observations: + raise RuntimeContractError( + f"{label}.expected_checks must be empty for a bounded observation error" + ) + if not special_observation_attempt and len(observations) > MAX_CI_EXPECTED_OBSERVATIONS: + raise RuntimeContractError(f"{label}.expected_checks exceeds its limit") + normalized_observations = [ + _validate_observed_check(item, f"{label}.expected_checks[{index}]") + for index, item in enumerate(observations) + ] + identities = [ + (item["workflow_name"], item["check_name"]) + for item in normalized_observations + ] + if identities != sorted(identities) or ( + len(set(identities)) != len(identities) + and reason != "duplicate_observed_check" + ): + raise RuntimeContractError( + f"{label}.expected_checks must be sorted and unique" + ) + retry = data["retry"] + if retry is not None: + retry_data = _closed( + retry, + {"job_id", "failure_fingerprint_sha256"}, + f"{label}.retry", + ) + if set(retry_data) != {"job_id", "failure_fingerprint_sha256"}: + raise RuntimeContractError(f"{label}.retry has missing fields") + retry = { + "job_id": _integer( + retry_data["job_id"], + f"{label}.retry.job_id", + minimum=1, + ), + "failure_fingerprint_sha256": _digest( + retry_data["failure_fingerprint_sha256"], + f"{label}.retry.failure_fingerprint_sha256", + ), + } + if retry["job_id"] > 9223372036854775807: + raise RuntimeContractError(f"{label}.retry.job_id is too large") + safe_error = data["safe_error"] + if safe_error is not None: + error_data = _closed( + safe_error, + {"code", "exit_code", "stdout_sha256", "stderr_sha256"}, + f"{label}.safe_error", + ) + if set(error_data) != {"code", "exit_code", "stdout_sha256", "stderr_sha256"}: + raise RuntimeContractError(f"{label}.safe_error has missing fields") + error_code = _string(error_data["code"], f"{label}.safe_error.code") + if error_code not in CI_ERROR_CODES: + raise RuntimeContractError(f"{label}.safe_error.code is unsupported") + error_exit = error_data["exit_code"] + if error_exit is not None: + error_exit = _integer( + error_exit, + f"{label}.safe_error.exit_code", + minimum=-255, + ) + if error_exit > 255: + raise RuntimeContractError( + f"{label}.safe_error.exit_code is too large" + ) + safe_error = { + "code": error_code, + "exit_code": error_exit, + "stdout_sha256": _digest( + error_data["stdout_sha256"], + f"{label}.safe_error.stdout_sha256", + ), + "stderr_sha256": _digest( + error_data["stderr_sha256"], + f"{label}.safe_error.stderr_sha256", + ), + } + if special_observation_attempt and retry is not None: + raise RuntimeContractError( + f"{label}.retry must be null for a bounded observation error" + ) + if ( + special_observation_attempt + and safe_error is not None + and safe_error["code"] == "hard_limit" + and unexpected_count != 0 + ): + raise RuntimeContractError( + f"{label}.unexpected_check_count must be zero for hard_limit" + ) + if ( + special_observation_attempt + and safe_error is not None + and safe_error["code"] == "hard_limit" + ): + if data["unexpected_checks_sha256"] != canonical_sha256([]): + raise RuntimeContractError( + f"{label}.unexpected_checks_sha256 must represent empty observations" + ) + empty_output_digest = sha256_bytes(b"") + if safe_error["stdout_sha256"] != empty_output_digest: + raise RuntimeContractError( + f"{label}.safe_error.stdout_sha256 must represent empty output" + ) + if safe_error["stderr_sha256"] != empty_output_digest: + raise RuntimeContractError( + f"{label}.safe_error.stderr_sha256 must represent empty output" + ) + if special_observation_attempt and unexpected_count > 2147483647: + raise RuntimeContractError( + f"{label}.unexpected_check_count is too large" + ) + if not special_observation_attempt and unexpected_count > MAX_CI_UNEXPECTED_OBSERVATIONS: + raise RuntimeContractError( + f"{label}.unexpected_check_count is too large" + ) + result = { + "sequence": sequence, + "head_oid": _commit(data["head_oid"], f"{label}.head_oid"), + "base_oid": _commit(data["base_oid"], f"{label}.base_oid"), + "reason": reason, + "watcher_exit_code": watcher_exit, + "expected_checks": normalized_observations, + "unexpected_check_count": unexpected_count, + "unexpected_checks_sha256": _digest( + data["unexpected_checks_sha256"], + f"{label}.unexpected_checks_sha256", + ), + "retry": retry, + "safe_error": safe_error, + } + if len(canonical_bytes(result)) > MAX_CI_ATTEMPT_BYTES: + raise CiAttemptSizeLimit( + f"{label} exceeds the {MAX_CI_ATTEMPT_BYTES}-byte limit" + ) + return result + + +def validate_ci_report(value: Mapping[str, Any]) -> dict[str, Any]: + keys = { + "schema", + "mode", + "repository", + "pull_number", + "runtime_source_envelope_digest", + "expected_ci_checks_sha256", + "discarded_attempt_count", + "discarded_attempts_sha256", + "attempts", + } + data = _closed(value, keys, "CI report") + if set(data) != keys: + raise RuntimeContractError("CI report has missing fields") + if data["schema"] != CI_REPORT_SCHEMA: + raise RuntimeContractError("unsupported CI report schema") + mode = _string(data["mode"], "CI report.mode") + if mode not in {"expected-v1", "legacy-schema3-observed-checks-v1"}: + raise RuntimeContractError("CI report.mode is unsupported") + repository = _string(data["repository"], "CI report.repository") + if not REPOSITORY_RE.fullmatch(repository): + raise RuntimeContractError("CI report.repository has an invalid shape") + pull = _integer(data["pull_number"], "CI report.pull_number", minimum=1) + if pull > 2147483647: + raise RuntimeContractError("CI report.pull_number is too large") + envelope = data["runtime_source_envelope_digest"] + if envelope is not None: + envelope = _digest(envelope, "CI report.runtime_source_envelope_digest") + expected_digest = data["expected_ci_checks_sha256"] + if expected_digest is not None: + expected_digest = _digest( + expected_digest, + "CI report.expected_ci_checks_sha256", + ) + if mode == "expected-v1" and ( + envelope is None or expected_digest is None + ): + raise RuntimeContractError( + "expected-v1 CI reports require runtime and expected-check digests" + ) + if mode == "legacy-schema3-observed-checks-v1" and ( + envelope is not None or expected_digest is not None + ): + raise RuntimeContractError( + "legacy CI reports must not carry v2 authority digests" + ) + discarded_count = _integer( + data["discarded_attempt_count"], + "CI report.discarded_attempt_count", + ) + discarded_digest = _digest( + data["discarded_attempts_sha256"], + "CI report.discarded_attempts_sha256", + ) + attempts = data["attempts"] + if not isinstance(attempts, list) or not attempts: + raise RuntimeContractError("CI report attempts must be non-empty") + normalized = [ + _validate_ci_attempt(item, f"CI report.attempts[{index}]") + for index, item in enumerate(attempts) + ] + if [item["sequence"] for item in normalized] != list( + range(normalized[0]["sequence"], normalized[0]["sequence"] + len(normalized)) + ): + raise RuntimeContractError("CI report attempt sequences are not contiguous") + if normalized[0]["sequence"] < 1: + raise RuntimeContractError("CI report attempt sequence is invalid") + if normalized[0]["sequence"] != discarded_count + 1: + raise RuntimeContractError( + "CI report attempt sequence does not match discarded count" + ) + result = { + "schema": CI_REPORT_SCHEMA, + "mode": mode, + "repository": repository, + "pull_number": pull, + "runtime_source_envelope_digest": envelope, + "expected_ci_checks_sha256": expected_digest, + "discarded_attempt_count": discarded_count, + "discarded_attempts_sha256": discarded_digest, + "attempts": normalized, + } + if len(normalized) > MAX_CI_ATTEMPTS: + raise RuntimeContractError("CI report retains too many attempts") + if len(canonical_bytes(result)) > MAX_CI_REPORT_BYTES: + raise RuntimeContractError("CI report exceeds its byte limit") + return result + + +def discarded_attempt_digest( + previous_digest: str, + attempt: Mapping[str, Any], +) -> str: + previous_digest = _digest(previous_digest, "previous discarded digest") + attempt_bytes = canonical_bytes(_validate_ci_attempt(attempt, "attempt")) + return sha256_bytes( + b"github-ci-discarded-v1\0" + + bytes.fromhex(previous_digest) + + attempt_bytes + ) + + +def make_observation_limit_attempt( + *, + sequence: int, + head_oid: str, + base_oid: str, + receipt: RejectedObservationReceipt, + watcher_exit_code: int | None = None, +) -> dict[str, Any]: + if not isinstance(receipt, RejectedObservationReceipt): + raise RuntimeContractError("observation limit requires a receipt") + return _validate_ci_attempt( + { + "sequence": sequence, + "head_oid": head_oid, + "base_oid": base_oid, + "reason": "report_invalid", + "watcher_exit_code": watcher_exit_code, + "expected_checks": [], + "unexpected_check_count": receipt.count, + "unexpected_checks_sha256": receipt.sha256, + "retry": None, + "safe_error": { + "code": "observation_limit", + "exit_code": watcher_exit_code, + "stdout_sha256": sha256_bytes(b""), + "stderr_sha256": sha256_bytes(b""), + }, + }, + "observation_limit_attempt", + ) + + +def make_observation_hard_limit_attempt( + *, + sequence: int, + head_oid: str, + base_oid: str, + hard_limit: RejectedObservationHardLimit, + watcher_exit_code: int | None = None, +) -> dict[str, Any]: + if not isinstance(hard_limit, RejectedObservationHardLimit): + raise RuntimeContractError("hard limit requires a hard-limit receipt") + empty_digest = canonical_sha256([]) + empty_output_digest = sha256_bytes(b"") + return _validate_ci_attempt( + { + "sequence": sequence, + "head_oid": head_oid, + "base_oid": base_oid, + "reason": "report_invalid", + "watcher_exit_code": watcher_exit_code, + "expected_checks": [], + "unexpected_check_count": 0, + "unexpected_checks_sha256": empty_digest, + "retry": None, + "safe_error": { + "code": "hard_limit", + "exit_code": watcher_exit_code, + "stdout_sha256": empty_output_digest, + "stderr_sha256": empty_output_digest, + }, + }, + "observation_hard_limit_attempt", + ) + + +def prepare_ci_attempt( + value: Mapping[str, Any], + projected_receipt: RejectedObservationReceipt, +) -> dict[str, Any]: + if not isinstance(projected_receipt, RejectedObservationReceipt): + raise RuntimeContractError("attempt preparation requires a receipt") + if projected_receipt.source != "projected_rows": + raise RuntimeContractError( + "attempt preparation requires a projected_rows receipt" + ) + try: + return _validate_ci_attempt(value, "attempt") + except CiAttemptSizeLimit: + return make_observation_limit_attempt( + sequence=value["sequence"], + head_oid=value["head_oid"], + base_oid=value["base_oid"], + receipt=projected_receipt, + watcher_exit_code=value.get("watcher_exit_code"), + ) + + +def make_report_invalid_attempt( + *, + sequence: int, + head_oid: str, + base_oid: str, + raw_observations: Sequence[Any], + watcher_exit_code: int | None = None, + stdout: bytes = b"", + stderr: bytes = b"", +) -> dict[str, Any]: + """Build the bounded fail-closed attempt for rejected observations.""" + + sequence = _integer(sequence, "sequence", minimum=1) + if sequence > 2147483647: + raise RuntimeContractError("sequence is too large") + head_oid = _commit(head_oid, "head_oid") + base_oid = _commit(base_oid, "base_oid") + if watcher_exit_code is not None: + watcher_exit_code = _integer( + watcher_exit_code, + "watcher_exit_code", + minimum=-255, + ) + if watcher_exit_code > 255: + raise RuntimeContractError("watcher_exit_code is too large") + if not isinstance(raw_observations, Sequence) or isinstance( + raw_observations, + (str, bytes), + ): + raise RuntimeContractError("raw_observations must be a sequence") + if not isinstance(stdout, bytes) or not isinstance(stderr, bytes): + raise RuntimeContractError("watcher output must be bytes") + count = len(raw_observations) + if count > MAX_CI_UNEXPECTED_OBSERVATIONS: + raise RuntimeContractError("raw observations exceed their hard limit") + return _validate_ci_attempt( + { + "sequence": sequence, + "head_oid": head_oid, + "base_oid": base_oid, + "reason": "report_invalid", + "watcher_exit_code": watcher_exit_code, + "expected_checks": [], + "unexpected_check_count": count, + "unexpected_checks_sha256": canonical_sha256( + list(raw_observations) + ), + "retry": None, + "safe_error": { + "code": "observation_limit", + "exit_code": watcher_exit_code, + "stdout_sha256": sha256_bytes(stdout), + "stderr_sha256": sha256_bytes(stderr), + }, + }, + "report_invalid_attempt", + ) + + +def _normalize_ci_attempt_for_build( + value: Mapping[str, Any], + label: str, +) -> dict[str, Any]: + try: + return _validate_ci_attempt(value, label) + except RuntimeContractError: + if isinstance(value, Mapping) and isinstance( + value.get("expected_checks"), + list, + ): + skeleton = dict(value) + skeleton["expected_checks"] = [] + _validate_ci_attempt(skeleton, f"{label}.authority") + sequence = value.get("sequence", 1) + head_oid = value.get("head_oid", "0" * 40) + base_oid = value.get("base_oid", "0" * 40) + if ( + isinstance(sequence, int) + and not isinstance(sequence, bool) + and 1 <= sequence <= 2147483647 + and isinstance(head_oid, str) + and isinstance(base_oid, str) + and SHA1_RE.fullmatch(head_oid) + and SHA1_RE.fullmatch(base_oid) + ): + return make_report_invalid_attempt( + sequence=sequence, + head_oid=head_oid, + base_oid=base_oid, + raw_observations=value["expected_checks"], + watcher_exit_code=( + value.get("watcher_exit_code") + if isinstance(value.get("watcher_exit_code"), int) + and not isinstance(value.get("watcher_exit_code"), bool) + else None + ), + ) + raise + + +def _bound_ci_report( + *, + mode: str, + repository: str, + pull_number: int, + runtime_source_envelope_digest: str | None, + expected_ci_checks_sha256: str | None, + retained: list[dict[str, Any]], + discarded_attempt_count: int, + discarded_attempts_sha256: str, +) -> dict[str, Any]: + if not retained: + raise RuntimeContractError("CI report needs one current attempt") + while True: + first_sequence = discarded_attempt_count + 1 + for index, item in enumerate(retained, start=first_sequence): + if item["sequence"] != index: + raise RuntimeContractError( + "CI report attempts must have contiguous global sequences" + ) + report = { + "schema": CI_REPORT_SCHEMA, + "mode": mode, + "repository": repository, + "pull_number": pull_number, + "runtime_source_envelope_digest": runtime_source_envelope_digest, + "expected_ci_checks_sha256": expected_ci_checks_sha256, + "discarded_attempt_count": discarded_attempt_count, + "discarded_attempts_sha256": discarded_attempts_sha256, + "attempts": retained, + } + if ( + len(retained) <= MAX_CI_ATTEMPTS + and len(canonical_bytes(report)) <= MAX_CI_REPORT_BYTES + ): + return validate_ci_report(report) + if len(retained) == 1: + raise RuntimeContractError( + "latest CI attempt cannot fit the report limit" + ) + discarded_attempts_sha256 = discarded_attempt_digest( + discarded_attempts_sha256, + retained.pop(0), + ) + discarded_attempt_count += 1 + + +def build_ci_report( + *, + mode: str, + repository: str, + pull_number: int, + runtime_source_envelope_digest: str | None, + expected_ci_checks_sha256: str | None, + attempts: Sequence[Mapping[str, Any]], + discarded_attempts: Sequence[Mapping[str, Any]] = (), +) -> dict[str, Any]: + normalized_discarded = [ + _validate_ci_attempt(item, f"discarded_attempts[{index}]") + for index, item in enumerate(discarded_attempts) + ] + if [ + item["sequence"] for item in normalized_discarded + ] != list(range(1, len(normalized_discarded) + 1)): + raise RuntimeContractError( + "discarded attempts must have contiguous initial sequences" + ) + normalized_attempts = [ + _normalize_ci_attempt_for_build(item, f"attempts[{index}]") + for index, item in enumerate(attempts) + ] + if not normalized_attempts: + raise RuntimeContractError("CI report needs one current attempt") + discarded_digest = "0" * 64 + for item in normalized_discarded: + discarded_digest = discarded_attempt_digest(discarded_digest, item) + return _bound_ci_report( + mode=mode, + repository=repository, + pull_number=pull_number, + runtime_source_envelope_digest=runtime_source_envelope_digest, + expected_ci_checks_sha256=expected_ci_checks_sha256, + retained=list(normalized_attempts), + discarded_attempt_count=len(normalized_discarded), + discarded_attempts_sha256=discarded_digest, + ) + + +def append_ci_report_attempt( + previous_report: Mapping[str, Any], + attempt: Mapping[str, Any], + *, + discarded_attempts: Sequence[Mapping[str, Any]] = (), +) -> dict[str, Any]: + """Append one watcher attempt while preserving the bounded history chain.""" + + previous = ( + validate_ci_report_history(previous_report, discarded_attempts) + if discarded_attempts + else validate_ci_report(previous_report) + ) + normalized = _normalize_ci_attempt_for_build(attempt, "attempt") + if normalized["sequence"] != previous["attempts"][-1]["sequence"] + 1: + raise RuntimeContractError( + "appended CI attempt sequence is not the next global sequence" + ) + return _bound_ci_report( + mode=previous["mode"], + repository=previous["repository"], + pull_number=previous["pull_number"], + runtime_source_envelope_digest=previous[ + "runtime_source_envelope_digest" + ], + expected_ci_checks_sha256=previous["expected_ci_checks_sha256"], + retained=[*previous["attempts"], normalized], + discarded_attempt_count=previous["discarded_attempt_count"], + discarded_attempts_sha256=previous["discarded_attempts_sha256"], + ) + + +def validate_ci_report_history( + value: Mapping[str, Any], + discarded_attempts: Sequence[Mapping[str, Any]] = (), +) -> dict[str, Any]: + report = validate_ci_report(value) + discarded = "0" * 64 + count = 0 + for index, attempt in enumerate(discarded_attempts): + normalized = _validate_ci_attempt( + attempt, + f"discarded_attempts[{index}]", + ) + if normalized["sequence"] != index + 1: + raise RuntimeContractError( + "discarded attempt sequences are not contiguous" + ) + discarded = discarded_attempt_digest(discarded, normalized) + count += 1 + if count != report["discarded_attempt_count"]: + raise RuntimeContractError("CI report discarded count is invalid") + if discarded != report["discarded_attempts_sha256"]: + raise RuntimeContractError("CI report discarded digest is invalid") + return report + + +__all__ = [ + "CI_REPORT_SCHEMA", + "CiAttemptSizeLimit", + "ExpectedCiClassification", + "EXPECTED_CHECKS_SCHEMA", + "EXTERNAL_CAPTURE_SCHEMA", + "MAX_CANONICAL_JSON_NESTING", + "PR_CURSOR_SCHEMA", + "RuntimeContractError", + "RuntimeAuthorityBinding", + "RuntimeExecutionContext", + "RuntimeExternalRoot", + "RejectedObservationHardLimit", + "RejectedObservationReceipt", + "SelectedRuntimeSourceInputs", + "TERMINAL_MARKER_SCHEMA", + "TERMINAL_SEAL_REQUEST_SCHEMA", + "TERMINAL_SEAL_SCHEMA", + "VERIFICATION_REPORT_SCHEMA", + "build_ci_report", + "append_ci_report_attempt", + "build_terminal_marker", + "build_terminal_seal_record", + "canonical_bytes", + "canonical_json_bytes", + "canonical_sha256", + "capture_runtime_source_envelope", + "capture_runtime_authority_binding", + "capture_runtime_execution_context", + "check_state_sha256", + "classify_expected_ci_checks", + "classify_expected_ci_checks_with_receipt", + "classify_ci_report", + "classify_pr_feedback", + "classify_terminal_state", + "classify_verification_report", + "discarded_attempt_digest", + "expected_ci_checks_sha256", + "make_observation_hard_limit_attempt", + "make_observation_limit_attempt", + "make_pr_feedback_cursor", + "make_report_invalid_attempt", + "parse_runtime_external_captures", + "parse_canonical_json", + "parse_terminal_marker_line", + "prepare_ci_attempt", + "revalidate_runtime_source_envelope", + "sha256_bytes", + "terminal_marker_line", + "validate_ci_report", + "validate_ci_report_history", + "validate_captured_runtime_source_envelope", + "validate_cleanup_report", + "validate_expected_ci_checks", + "validate_pr_feedback_cursor", + "validate_pr_feedback_item", + "validate_runtime_source_envelope", + "validate_terminal_chain", + "validate_terminal_marker", + "validate_terminal_seal_record", + "validate_terminal_seal_request", + "validate_verification_report", +] diff --git a/.kent/workflow-profile.toml b/.kent/workflow-profile.toml index e34afbc2..ed4542ee 100644 --- a/.kent/workflow-profile.toml +++ b/.kent/workflow-profile.toml @@ -17,7 +17,29 @@ kit_managed_adapters = [ ] source_control = "github" issue_tracker = "none" -release_topology = "tag-after-merged-release-pr" +kit_managed_commands = [ + "runtime_contracts", + "verify", + "evidence", + "janitor", + "wait_pr", + "wait_ci", +] + +[command_versions] +runtime_contracts = "2.0.0" +verify = "2.0.0" +evidence = "2.0.0" +janitor = "2.0.0" +wait_pr = "2.0.0" +wait_ci = "2.0.0" + +[release] +topology_kind = "puber-release" +adoption_mode = "managed-in-place" +spec_path = ".kent/workflows/specs/puber-release.toml" +builder_path = ".kent/workflows/builders/puber_release.py" +snapshot_path = ".kent/workflows/puber-release.json" [execution] default_target = "ask-on-first-execution" @@ -45,7 +67,9 @@ spec_review = true [commands] branch_identity = ".kent/scripts/workflow-branch-identity" dispatch = ".kent/scripts/workflow-verification-dispatch" +runtime_contracts = ".kent/scripts/workflow_runtime_contracts.py" verify = ".kent/scripts/workflow-verify-report" +compile_verify = ".kent/scripts/workflow-compile-verify" checkpoint = ".kent/scripts/workflow-checkpoint" evidence = ".kent/scripts/workflow-evidence-ledger" plan_contract = ".kent/scripts/workflow-plan-contract" diff --git a/.kent/workflows/builders/puber_release.py b/.kent/workflows/builders/puber_release.py index 8438c7db..1598eecd 100755 --- a/.kent/workflows/builders/puber_release.py +++ b/.kent/workflows/builders/puber_release.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate the tracked schema-4 Puber Release graph offline.""" +"""Deterministic, source-only validator for the Puber Release graph.""" from __future__ import annotations import argparse @@ -8,62 +8,221 @@ from pathlib import Path import sys import tomllib +from typing import Any -WORKFLOW_ID = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" ROOT = Path(__file__).resolve().parents[3] +GRAPH = ROOT / ".kent/workflows/puber-release.json" SPEC = ROOT / ".kent/workflows/specs/puber-release.toml" MANIFEST = ROOT / ".kent/workflows/puber-release.manifest.json" +WORKFLOW_ID = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" +WORKFLOW_NAME = "Puber Release" +BASE_NODE_IDS = { + "backlog": "69c194f1-46fa-43f9-ba62-95e5332fe6e5", + "prepare": "4f98b2c1-ef8c-4323-8040-b9a40e939910", + "compliance": "e7444395-765d-422b-890e-5b2dd69f52e4", + "ship_pr": "2456358a-92e6-44ca-b8f1-846517d88032", + "ci_monitor": "bd9e337e-7189-412e-8735-3dbdb6dcf5b1", + "publish": "7ab29a1a-9ab5-40c4-ae91-1c6f47c12941", + "monitor": "ac6c3970-99e4-4b40-841b-9dc7da036159", + "cleanup": "16a1dcd7-2737-45e9-88ba-93ebc8430b89", + "wont_do": "dd3e4e97-a974-4e6a-bafe-da1d891bb5ce", + "waiting_pr": "94e4c8ab-b0d7-45cf-b922-3a970a6e3eee", + "done": "7dfc3956-53ec-4307-ae59-daf1a3dd1151", +} +NEW_NODES = { + "release_intent_gate": ("0b0ccf3b-8ad1-5e3c-a982-b2b836432a5e", ".kent/scripts/workflow-puber-release-intent"), + "ci_watch": ("598063c6-2cef-51ec-a613-043e5b6335db", ".kent/scripts/workflow-wait-github-ci"), + "merge_watch": ("44390763-8581-53aa-8700-e912a67aaca4", ".kent/scripts/workflow-wait-github-pr"), + "task_janitor": ("dfbd8b53-4c7c-56e7-bcd0-631389648090", ".kent/scripts/workflow-task-janitor"), +} +SCRIPT_NODES = { + "publish": ".kent/scripts/workflow-puber-release-publish", + "monitor": ".kent/scripts/workflow-wait-github-release", + "cleanup": ".kent/scripts/workflow-release-cleanup", +} +EXPECTED_NODES = set(BASE_NODE_IDS) | set(NEW_NODES) +EXPECTED_COMMANDS = { + ":app:detektAll", + ":app:testProdDebugUnitTest", + ":app:assembleProdDebug", +} + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def load_object(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError(f"{path} must contain one JSON object") + return value + + +def check_graph(path: Path) -> list[str]: + source = load_object(path) + errors: list[str] = [] + workflow = source.get("workflow", {}) + if workflow != { + "id": WORKFLOW_ID, + "name": WORKFLOW_NAME, + "description": workflow.get("description"), + "version": 88, + "execution_target_policy": {"mode": "head"}, + "schema_version": 4, + "default": False, + "source_revision": 88, + }: + errors.append("workflow identity, revision, schema, or default drifted") + nodes = source.get("nodes", []) + groups = source.get("transition_groups", []) + edges = source.get("edges", []) + if (len(nodes), len(groups), len(edges)) != (15, 42, 42): + errors.append("graph counts must be exactly 15 nodes, 42 groups, 42 edges") + by_key = {node.get("key"): node for node in nodes} + if set(by_key) != EXPECTED_NODES: + errors.append("graph node keys differ from the closed revision-88 set") + for key, node_id in BASE_NODE_IDS.items(): + if by_key.get(key, {}).get("id") != node_id: + errors.append(f"retained node id drifted: {key}") + for key, (node_id, script) in NEW_NODES.items(): + node = by_key.get(key, {}) + if node.get("id") != node_id or node.get("kind") != "script" or node.get("script_path") != script: + errors.append(f"new script node drifted: {key}") + for key, script in SCRIPT_NODES.items(): + node = by_key.get(key, {}) + if node.get("kind") != "script" or node.get("script_path") != script: + errors.append(f"publication node is not the required script: {key}") + if "subagent_role" in node or "completion_mode" in node: + errors.append(f"script node retains agent-only fields: {key}") + publish_id = BASE_NODE_IDS["publish"] + incoming = [edge for edge in edges if edge.get("target_node_id") == publish_id] + if not incoming or any(edge.get("requires_approval") is not True for edge in incoming): + errors.append("every publication entry must require approval") + for node in nodes: + if node.get("workflow_id") != WORKFLOW_ID: + errors.append(f"node workflow identity drifted: {node.get('key')}") + for item in groups: + if item.get("workflow_id") != WORKFLOW_ID: + errors.append("transition group workflow identity drifted") + for edge in edges: + if edge.get("workflow_id") != WORKFLOW_ID: + errors.append("edge workflow identity drifted") + return errors + + +def check_workflows() -> list[str]: + errors: list[str] = [] + pr = (ROOT / ".github/workflows/pr-checks.yml").read_text(encoding="utf-8") + release = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + if "permissions:\n contents: read" not in pr: + errors.append("PR Checks must declare read-only contents permission") + if "pull_request:" not in pr or "workflow_dispatch:" in pr: + errors.append("PR Checks event source drifted") + for job in ("detekt:", "unit-tests:", "build:"): + if pr.count("\n " + job) != 1: + errors.append(f"PR Checks missing exact job {job[:-1]}") + if any(token in pr for token in ("secrets.", "RELEASE_", "gh release", "upload-artifact", "push:", "workflow_run")): + errors.append("PR Checks contains a release or credential effect") + for command in EXPECTED_COMMANDS: + if command not in pr: + errors.append(f"PR Checks missing exact command {command}") + if "java-version: '21'" not in pr and 'java-version: "21"' not in pr: + errors.append("PR Checks must use Java 21") + if "tags:" not in release or "workflow_dispatch:" in release: + errors.append("release workflow must be tag-push only") + if "--generate-notes=false" not in release or '--notes ""' not in release: + errors.append("release workflow must create an empty non-generated initial body") + if "sha256sum" not in release or "assembleProdRelease" not in release: + errors.append("release workflow must build and checksum the tagged APK") + return errors + + +def check_manifest() -> list[str]: + errors: list[str] = [] + value = load_object(MANIFEST) + required = {"schema", "closure_algorithm", "project_name", "repository", "topology_kind", "additional_paths", "additional_trees", "declared_prompt_references", "external_roots", "runtime_attested"} + if set(value) != required: + errors.append("manifest has unknown or missing closed fields") + if value.get("schema") != "release_source_manifest_v1" or value.get("closure_algorithm") != "project-instruction-closure-v1": + errors.append("manifest schema or closure algorithm drifted") + if value.get("project_name") != "Puber" or value.get("repository") != "rovkinmax/Puber" or value.get("topology_kind") != "puber-release": + errors.append("manifest project identity drifted") + paths = value.get("additional_paths", []) + if paths != sorted(set(paths)): + errors.append("manifest additional_paths must be sorted and unique") + for relative in paths: + candidate = ROOT / relative + if not candidate.is_file() or candidate.is_symlink(): + errors.append(f"manifest source is missing or unsafe: {relative}") + roots = value.get("external_roots", []) + expected = {item.get("key"): item for item in roots if isinstance(item, dict)} + builder_key = f"builder-sha256={digest(Path(__file__))}" + if not any(item.get("kind") == "builder-sha256" and item.get("key") == digest(Path(__file__)) for item in roots if isinstance(item, dict)): + errors.append("manifest does not bind the executable builder digest") + for item in roots: + if not isinstance(item, dict) or item.get("runtime_digest_required") is not True: + errors.append("manifest external roots must require runtime digests") + if isinstance(item, dict) and item.get("kind") == "source-sha256": + raw = str(item.get("key", "")) + if "=" not in raw: + errors.append("source digest binding is malformed") + continue + relative, expected_digest = raw.split("=", 1) + if relative not in paths or not (ROOT / relative).is_file() or digest(ROOT / relative) != expected_digest: + errors.append(f"source digest binding drifted: {relative}") + return errors + + +def check_spec() -> list[str]: + errors: list[str] = [] + try: + raw = tomllib.loads(SPEC.read_text(encoding="utf-8")) + except Exception as exc: + return [f"release spec is not TOML: {exc}"] + if raw.get("schema_version") != 2 or raw.get("spec_kind") != "release": + errors.append("release spec must use schema 2 release contract") + if raw.get("topology_kind") != "puber-release" or raw.get("adoption_mode") != "managed-in-place": + errors.append("release spec topology drifted") + if raw.get("repository") != "rovkinmax/Puber" or raw.get("project_name") != "Puber": + errors.append("release spec identity drifted") + intent = raw.get("workflow_source_intent", {}) + if intent.get("id") != WORKFLOW_ID or intent.get("name") != WORKFLOW_NAME or intent.get("expected_project_default") is not False: + errors.append("release spec workflow source intent drifted") + required = raw.get("required_jobs_v1", {}).get("jobs", []) + if {row.get("job_key") for row in required} != {"detekt", "unit-tests", "build"}: + errors.append("release spec required job closure is not exact") + if raw.get("qualification_jobs_v1", {}).get("jobs") != []: + errors.append("release spec must not invent qualification jobs") + variants = raw.get("operation_variants", []) + if not any(v.get("key") == "publish_after_merge" and v.get("approval_required") is True for v in variants): + errors.append("release spec lacks approval-required publication variant") + if not any(v.get("authority_kind", {}).get("kind") == "github_run_template" for v in variants): + errors.append("release spec lacks exact github_run operation variant") + return errors + + +def check_source(path: Path) -> list[str]: + errors = check_graph(path) + check_manifest() + check_spec() + check_workflows() + return errors def main() -> int: parser = argparse.ArgumentParser() - parser.add_argument("--check", type=Path, required=True) + parser.add_argument("--check", type=Path) + parser.add_argument("--emit", type=Path) args = parser.parse_args() - try: - graph_path = args.check.resolve() - graph = json.loads(graph_path.read_text()) - spec = tomllib.loads(SPEC.read_text()) - manifest = json.loads(MANIFEST.read_text()) - except (OSError, json.JSONDecodeError, tomllib.TOMLDecodeError) as error: - print(f"puber-release: {error}", file=sys.stderr) - return 1 - - workflow = graph.get("workflow", {}) - expected = { - "nodes": int(spec["nodes"]), - "transition_groups": int(spec["transition_groups"]), - "edges": int(spec["edges"]), - } - actual = { - "nodes": len(graph.get("nodes", [])), - "transition_groups": len(graph.get("transition_groups", [])), - "edges": len(graph.get("edges", [])), - } - checks = [ - (workflow.get("id") == WORKFLOW_ID, "workflow UUID is not canonical"), - (workflow.get("name") == "Puber Release", "workflow name is not canonical"), - (workflow.get("version") == 88, "tracked source revision must be 88"), - (workflow.get("schema_version") == 4, "schema version must be 4"), - (workflow.get("default") is False, "Puber Release must remain non-default"), - (actual == expected, f"graph counts mismatch: expected {expected}, got {actual}"), - (manifest.get("workflow", {}).get("counts") == expected, "manifest counts mismatch"), - (manifest.get("no_live_apply") is True, "manifest must declare no_live_apply=true"), - ] - for passed, message in checks: - if not passed: - print(f"puber-release: {message}", file=sys.stderr) - return 1 - - nodes = {node.get("key"): node for node in graph.get("nodes", [])} - for key, script_path in zip(spec["required_node_keys"], spec["required_script_paths"]): - if nodes.get(key, {}).get("script_path") != script_path: - print(f"puber-release: {key} is not bound to {script_path}", file=sys.stderr) - return 1 - digest = hashlib.sha256(graph_path.read_bytes()).hexdigest() - if manifest.get("graph_sha256") != digest: - print("puber-release: manifest graph digest mismatch", file=sys.stderr) + if bool(args.check) == bool(args.emit): + parser.error("choose exactly one of --check or --emit") + if args.emit: + args.emit.write_text(GRAPH.read_text(encoding="utf-8"), encoding="utf-8") + return 0 + errors = check_source(args.check) + if errors: + for error in errors: + print(f"builder: {error}", file=sys.stderr) return 1 - print(json.dumps({"status": "passed", "revision": 88, "counts": actual}, sort_keys=True)) + print("puber-release: deterministic source check passed") return 0 diff --git a/.kent/workflows/puber-release.json b/.kent/workflows/puber-release.json index 1b85b6da..bcab04f6 100644 --- a/.kent/workflows/puber-release.json +++ b/.kent/workflows/puber-release.json @@ -64,31 +64,28 @@ "id": "7ab29a1a-9ab5-40c4-ae91-1c6f47c12941", "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "key": "publish", - "kind": "agent", + "kind": "script", "display_name": "Publish Tag", "group_id": null, - "subagent_role": "release-manager", - "completion_mode": "shell_command" + "script_path": ".kent/scripts/workflow-puber-release-publish" }, { "id": "ac6c3970-99e4-4b40-841b-9dc7da036159", "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "key": "monitor", - "kind": "agent", + "kind": "script", "display_name": "Monitor Release", "group_id": null, - "subagent_role": "ci-monitor", - "completion_mode": "shell_command" + "script_path": ".kent/scripts/workflow-wait-github-release" }, { "id": "16a1dcd7-2737-45e9-88ba-93ebc8430b89", "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "key": "cleanup", - "kind": "agent", + "kind": "script", "display_name": "Cleanup", "group_id": null, - "subagent_role": "delivery-operator", - "completion_mode": "shell_command" + "script_path": ".kent/scripts/workflow-release-cleanup" }, { "id": "dd3e4e97-a974-4e6a-bafe-da1d891bb5ce", diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json index 9234f006..53114021 100644 --- a/.kent/workflows/puber-release.manifest.json +++ b/.kent/workflows/puber-release.manifest.json @@ -1,24 +1,206 @@ { - "schema_version": 4, - "workflow": { - "id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", - "name": "Puber Release", - "revision": 88, - "default": false, - "counts": { - "nodes": 15, - "transition_groups": 42, - "edges": 42 + "schema": "release_source_manifest_v1", + "closure_algorithm": "project-instruction-closure-v1", + "project_name": "Puber", + "repository": "rovkinmax/Puber", + "topology_kind": "puber-release", + "additional_paths": [ + ".github/workflows/pr-checks.yml", + ".github/workflows/release.yml", + ".kent/commands/cleanup-task.md", + ".kent/commands/compliance-review.md", + ".kent/commands/release-branch.md", + ".kent/commands/release-tag.md", + ".kent/commands/release.md", + ".kent/commands/ship-pr.md", + ".kent/project-contract.md", + ".kent/scripts/tests/test-github-release-workflow", + ".kent/scripts/tests/test-puber-release-graph", + ".kent/scripts/tests/test-runtime-v2-command-closure", + ".kent/scripts/tests/test-workflow-puber-release-intent", + ".kent/scripts/tests/test-workflow-puber-release-publish", + ".kent/scripts/tests/test-workflow-release-cleanup", + ".kent/scripts/tests/test-workflow-verify-report", + ".kent/scripts/tests/test-workflow-wait-github-release", + ".kent/scripts/workflow-compile-verify", + ".kent/scripts/workflow-evidence-ledger", + ".kent/scripts/workflow-puber-release-intent", + ".kent/scripts/workflow-puber-release-publish", + ".kent/scripts/workflow-release-cleanup", + ".kent/scripts/workflow-task-janitor", + ".kent/scripts/workflow-verify-report", + ".kent/scripts/workflow-wait-github-ci", + ".kent/scripts/workflow-wait-github-pr", + ".kent/scripts/workflow-wait-github-release", + ".kent/scripts/workflow_runtime_contracts.py", + ".kent/workflows/README.md", + ".kent/workflows/builders/puber_release.py", + ".kent/workflows/puber-release.json", + ".kent/workflows/specs/puber-release.toml" + ], + "additional_trees": [], + "declared_prompt_references": [], + "external_roots": [ + { + "kind": "builder-sha256", + "key": "f3f8e302dc51b1e536f8fba3ddff9857f7ce46271e83cb7a63517105fb35e694", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".github/workflows/pr-checks.yml=e261e6fd62de561029fc926870bfd205450ed92a3e115ca252ece8dbe63c679c", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".github/workflows/release.yml=e7a61c83043eb79725ed03c58dedf516cdea460a0a22f6c8c85fc611b51db868", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/commands/cleanup-task.md=148470dfdb497ff1254ae69ed5c336f57bff2e05926bec83be43498d069c3ec4", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/commands/compliance-review.md=05085da2982fcfa23e51137baff494308c1cfbc6b70c2e522e4ac4002ccfe1a6", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/commands/release-branch.md=9c7d1f8e4ba0f9140370eb29d11f6609581b300567683810a990057fd3da7dba", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/commands/release-tag.md=762e05505e7774c016fd2a5826c3d9ab9b2afa02711f812f13f46a89bc78d6a5", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/commands/release.md=eb130f4290800db501d93537c65344d20a6371960c6daf9ff30737daeb03d9c3", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/commands/ship-pr.md=ab7cb72d33ef4ddb9e96f0f45d10edfe1ef0f5a3d1b0f3c08b816c7d704007ef", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/project-contract.md=e8dfa01ad1533d9ba3612f86f7b4be19967284416f8297cc3a69bd4880025c30", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/tests/test-github-release-workflow=0437c17d451e6bdb064b79b6c12c7a8c673fe1fa6923ee9c8d4449b737ad055e", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/tests/test-puber-release-graph=c356221923d844efc35742d3fae6fb998ae217ee922b39d4ff798f4485e0f2b9", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/tests/test-runtime-v2-command-closure=6d1b28991c9177be803afa411108d289a32ad5227cb0d79f3dbfac229f0d0541", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/tests/test-workflow-puber-release-intent=47ee3924a059101104ac835ae821cd1b1a8bbb1ac9ef1f4f9d757001ebb187e6", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/tests/test-workflow-puber-release-publish=fd4d7f845d546954c9bc91c4b9a689eb8f14fa6cbd34bd354529aaece85cf50a", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/tests/test-workflow-release-cleanup=2daa8232ba906ab861e2abc5d0f9e9714508c607cbca6f4966f40238929c1919", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/tests/test-workflow-verify-report=3def630671bd3831371038c4d7dc07bbd5b812b62d768ffa994ec7dfadc54a2d", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/tests/test-workflow-wait-github-release=dcbe7f3c52422776b5cdbf8d1bf8015a16da05d61a4bfed0ce637dfa7c9f1063", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/workflow-compile-verify=b5c78573399a6b741b71c09558e1d52448f8f355bcefb997a8e1cc5db2639c1c", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/workflow-evidence-ledger=82c65058924216b55257fdd74aa23015eeff6720f2b339be67cd134c20cd3a5b", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/workflow-puber-release-intent=afdd4f7b22e2174f6efb3c9ebd242d659c544e7a635b4bbbde045b92433356d5", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/workflow-puber-release-publish=3eb30e55722a9dbb17fc1444cfd014c4497de02427854c2f4cded585deb4ea5e", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/workflow-release-cleanup=294e052859c038ef34d6f5d1f99b9b2f62a5e389e7403de73ee747d04fd08a01", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/workflow-task-janitor=6b14f27a8df0f8cd1e002640564dcf20ad733f67d307ec382e5dee3710d550ba", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/workflow-verify-report=c1562e16021ec51652e963eb981db8e3d2d1530b78a20d28966de10510405e11", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/workflow-wait-github-ci=5e272b3a32200df034df0be5d76451853f26b0f0f58838d0b6e9fe7fc8a1c5d4", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/workflow-wait-github-pr=e16fe3b654a24168d4850c98d515cd28f9ce251329bc57b406d30dd012b599f4", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/workflow-wait-github-release=738f718a625137a9bfeae6de8b42e150f0ca71556ee0d9f58011169e73e218d7", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/scripts/workflow_runtime_contracts.py=0dd46123287c9238c8e003236ca99cce0ee719ef4003f28dbc77fdae033b209d", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/workflows/README.md=c7dead4ab4f73a0ebc095ad1838bdf557c4c0b3df007578e05ea78f76bf23de2", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/workflows/puber-release.json=441739e4270ec9a5e3fc8fbd6c123e4e142ced3eb7f234a7f074045598d1f363", + "runtime_digest_required": true + }, + { + "kind": "source-sha256", + "key": ".kent/workflows/specs/puber-release.toml=9867df0971eba6c8f30b52f4a37ea3144e1ef5ac08ab1e507790503ef6d9c58c", + "runtime_digest_required": true } - }, - "graph_path": ".kent/workflows/puber-release.json", - "spec_path": ".kent/workflows/specs/puber-release.toml", - "graph_sha256": "9723286b4859b300bc7dc15d05dd51c71feed45401fc2bdf57d0b5209060eef7", - "required_nodes": [ - "release_intent_gate", - "ci_watch", - "merge_watch", - "task_janitor" ], - "no_live_apply": true + "runtime_attested": false } diff --git a/.kent/workflows/specs/puber-release.toml b/.kent/workflows/specs/puber-release.toml index b0c5e8e3..1642e0d1 100644 --- a/.kent/workflows/specs/puber-release.toml +++ b/.kent/workflows/specs/puber-release.toml @@ -1,10 +1,489 @@ -schema_version = 4 -workflow_id = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" +schema_version = 2 +spec_kind = "release" +topology_kind = "puber-release" +adoption_mode = "managed-in-place" +project_name = "Puber" +repository = "rovkinmax/Puber" +runtime_attested = false + +[workflow_source_intent] name = "Puber Release" -default = false -source_revision = 88 -nodes = 15 -transition_groups = 42 -edges = 42 -required_node_keys = ["release_intent_gate", "ci_watch", "merge_watch", "task_janitor"] -required_script_paths = [".kent/scripts/workflow-puber-release-intent", ".kent/scripts/workflow-wait-github-ci", ".kent/scripts/workflow-wait-github-pr", ".kent/scripts/workflow-task-janitor"] +id = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" +update_kind = "graph-and-metadata" +expected_project_link = "non-default" +expected_project_default = false +allow_create = false +allow_default_change = false +allow_uuid_change = false + +[source_manifest] +schema = "release_source_manifest_v1" +path = ".kent/workflows/puber-release.manifest.json" +revision_binding = "runtime-source-envelope" +runtime_attested = false + +[required_jobs_v1] +schema = "required_jobs_v1" + +[[required_jobs_v1.jobs]] +contract_key = "detekt_contract" +workflow_path = ".github/workflows/pr-checks.yml" +job_key = "detekt" +job_display_name = "Detekt" +needs = [] +matrix = { } +condition = "" +continue_on_error = false +runs_on = "ubuntu-latest" +runner_trust = "github-hosted-standard-ephemeral" +credential_profile = "none" +allowed_effects = ["dependency-downloads", "github-actions-logs"] +skip_policy = "never" +branch_protection_required = true +control_plane_fixtures_forbidden = true +credential_scope_is_job_local = false +runner_environment_asserted = true +effective_permissions = { "contents" = "read" } +effective_defaults_run = { "shell" = "", "working_directory" = "" } +github_environment = "" +services = { } +container = { "image" = "", "environment" = { }, "ports" = [], "options" = "" } +checkout_persist_credentials = false +secret_refs = [] +effective_environment = { } +[required_jobs_v1.jobs.event_selector] +name = "pull_request" +branches = ["master"] +branches_ignore = [] +tags = [] +tags_ignore = [] +paths = [] +paths_ignore = [] +types = [] +dispatch_inputs = [] +[[required_jobs_v1.jobs.steps]] +kind = "uses" +name = "Checkout exact source" +condition = "" +continue_on_error = false +uses = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" +run = "" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = true +[required_jobs_v1.jobs.steps.with] +"persist-credentials" = false +[[required_jobs_v1.jobs.steps]] +kind = "uses" +name = "Set up Java 21" +condition = "" +continue_on_error = false +uses = "actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00" +run = "" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = true +[required_jobs_v1.jobs.steps.with] +"java-version" = "21" +"distribution" = "temurin" +[[required_jobs_v1.jobs.steps]] +kind = "run" +name = "Assert GitHub-hosted runner" +condition = "" +continue_on_error = false +uses = "" +with = { } +run = "test \"${RUNNER_ENVIRONMENT:-github-hosted}\" = github-hosted" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = true +[[required_jobs_v1.jobs.steps]] +kind = "run" +name = "Run Detekt" +condition = "" +continue_on_error = false +uses = "" +with = { } +run = "./gradlew :app:detektAll" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = true + +[[required_jobs_v1.jobs]] +contract_key = "unit-tests_contract" +workflow_path = ".github/workflows/pr-checks.yml" +job_key = "unit-tests" +job_display_name = "Unit Tests" +needs = [] +matrix = { } +condition = "" +continue_on_error = false +runs_on = "ubuntu-latest" +runner_trust = "github-hosted-standard-ephemeral" +credential_profile = "none" +allowed_effects = ["dependency-downloads", "github-actions-logs"] +skip_policy = "never" +branch_protection_required = true +control_plane_fixtures_forbidden = true +credential_scope_is_job_local = false +runner_environment_asserted = true +effective_permissions = { "contents" = "read" } +effective_defaults_run = { "shell" = "", "working_directory" = "" } +github_environment = "" +services = { } +container = { "image" = "", "environment" = { }, "ports" = [], "options" = "" } +checkout_persist_credentials = false +secret_refs = [] +effective_environment = { } +[required_jobs_v1.jobs.event_selector] +name = "pull_request" +branches = ["master"] +branches_ignore = [] +tags = [] +tags_ignore = [] +paths = [] +paths_ignore = [] +types = [] +dispatch_inputs = [] +[[required_jobs_v1.jobs.steps]] +kind = "uses" +name = "Checkout exact source" +condition = "" +continue_on_error = false +uses = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" +run = "" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = true +[required_jobs_v1.jobs.steps.with] +"persist-credentials" = false +[[required_jobs_v1.jobs.steps]] +kind = "uses" +name = "Set up Java 21" +condition = "" +continue_on_error = false +uses = "actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00" +run = "" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = true +[required_jobs_v1.jobs.steps.with] +"java-version" = "21" +"distribution" = "temurin" +[[required_jobs_v1.jobs.steps]] +kind = "run" +name = "Assert GitHub-hosted runner" +condition = "" +continue_on_error = false +uses = "" +with = { } +run = "test \"${RUNNER_ENVIRONMENT:-github-hosted}\" = github-hosted" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = true +[[required_jobs_v1.jobs.steps]] +kind = "run" +name = "Run unit tests" +condition = "" +continue_on_error = false +uses = "" +with = { } +run = "./gradlew :app:testProdDebugUnitTest" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = true + +[[required_jobs_v1.jobs]] +contract_key = "build_contract" +workflow_path = ".github/workflows/pr-checks.yml" +job_key = "build" +job_display_name = "Build" +needs = [] +matrix = { } +condition = "" +continue_on_error = false +runs_on = "ubuntu-latest" +runner_trust = "github-hosted-standard-ephemeral" +credential_profile = "none" +allowed_effects = ["dependency-downloads", "github-actions-logs"] +skip_policy = "never" +branch_protection_required = true +control_plane_fixtures_forbidden = true +credential_scope_is_job_local = false +runner_environment_asserted = true +effective_permissions = { "contents" = "read" } +effective_defaults_run = { "shell" = "", "working_directory" = "" } +github_environment = "" +services = { } +container = { "image" = "", "environment" = { }, "ports" = [], "options" = "" } +checkout_persist_credentials = false +secret_refs = [] +effective_environment = { } +[required_jobs_v1.jobs.event_selector] +name = "pull_request" +branches = ["master"] +branches_ignore = [] +tags = [] +tags_ignore = [] +paths = [] +paths_ignore = [] +types = [] +dispatch_inputs = [] +[[required_jobs_v1.jobs.steps]] +kind = "uses" +name = "Checkout exact source" +condition = "" +continue_on_error = false +uses = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" +run = "" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = true +[required_jobs_v1.jobs.steps.with] +"persist-credentials" = false +[[required_jobs_v1.jobs.steps]] +kind = "uses" +name = "Set up Java 21" +condition = "" +continue_on_error = false +uses = "actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00" +run = "" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = true +[required_jobs_v1.jobs.steps.with] +"java-version" = "21" +"distribution" = "temurin" +[[required_jobs_v1.jobs.steps]] +kind = "run" +name = "Assert GitHub-hosted runner" +condition = "" +continue_on_error = false +uses = "" +with = { } +run = "test \"${RUNNER_ENVIRONMENT:-github-hosted}\" = github-hosted" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = true +[[required_jobs_v1.jobs.steps]] +kind = "run" +name = "Build debug APK" +condition = "" +continue_on_error = false +uses = "" +with = { } +run = "./gradlew :app:assembleProdDebug" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = true + +[qualification_jobs_v1] +schema = "qualification_jobs_v1" +jobs = [] + +[effect_jobs_v1] +schema = "effect_jobs_v1" + +[[effect_jobs_v1.jobs]] +contract_key = "release_contract" +workflow_path = ".github/workflows/release.yml" +job_key = "release" +job_display_name = "Build & Publish" +needs = [] +matrix = { } +condition = "" +continue_on_error = false +runs_on = "ubuntu-latest" +runner_trust = "github-hosted-standard-ephemeral-effect" +credential_profile = "github-platform-contents-write" +allowed_effects = ["git-tag-create", "publish"] +skip_policy = "event-gated" +branch_protection_required = false +control_plane_fixtures_forbidden = true +credential_scope_is_job_local = true +runner_environment_asserted = true +effective_permissions = { "contents" = "write" } +effective_defaults_run = { "shell" = "", "working_directory" = "" } +github_environment = "" +services = { } +container = { "image" = "", "environment" = { }, "ports" = [], "options" = "" } +checkout_persist_credentials = false +secret_refs = [] +effective_environment = { } +[effect_jobs_v1.jobs.event_selector] +name = "push" +branches = [] +branches_ignore = [] +tags = ["v*"] +tags_ignore = [] +paths = [] +paths_ignore = [] +types = [] +dispatch_inputs = [] +[[effect_jobs_v1.jobs.steps]] +kind = "uses" +name = "Checkout exact tagged source" +condition = "" +continue_on_error = false +uses = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" +run = "" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = false +[effect_jobs_v1.jobs.steps.with] +"persist-credentials" = false +"ref" = "${{ github.ref }}" +[[effect_jobs_v1.jobs.steps]] +kind = "uses" +name = "Set up Java 21" +condition = "" +continue_on_error = false +uses = "actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00" +run = "" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = false +[effect_jobs_v1.jobs.steps.with] +"java-version" = "21" +"distribution" = "temurin" +[[effect_jobs_v1.jobs.steps]] +kind = "run" +name = "Build release APK" +condition = "" +continue_on_error = false +uses = "" +with = { } +run = "./gradlew :app:assembleProdRelease" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = false +[[effect_jobs_v1.jobs.steps]] +kind = "run" +name = "Prepare APK checksum" +condition = "" +continue_on_error = false +uses = "" +with = { } +run = "sha256sum app/build/outputs/apk/prod/release/*.apk > release.sha256" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = false +[[effect_jobs_v1.jobs.steps]] +kind = "uses" +name = "Upload APK" +condition = "" +continue_on_error = false +uses = "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02" +run = "" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = false +[effect_jobs_v1.jobs.steps.with] +"name" = "puber-${{ github.ref_name }}" +"path" = "app/build/outputs/apk/prod/release/*.apk\nrelease.sha256" +[[effect_jobs_v1.jobs.steps]] +kind = "run" +name = "Create empty GitHub Release" +condition = "" +continue_on_error = false +uses = "" +with = { } +run = "gh release create \"$GITHUB_REF_NAME\" --verify-tag --title \"Puber $GITHUB_REF_NAME\" --notes \"\" --generate-notes=false release.sha256 app/build/outputs/apk/prod/release/*.apk" +effective_shell = "" +effective_working_directory = "" +effective_environment = { "GH_TOKEN" = "${{ github.token }}" } +secret_refs = [] +validation_required = false + +[[operation_variants]] +key = "publish_after_merge" +operation_kind = "publish" +authority_transitions = ["publish_after_merge"] +required_job_contract_keys = ["build_contract", "detekt_contract", "unit-tests_contract"] +qualification_job_contract_keys = [] +effect_job_contract_keys = ["release_contract"] +approval_required = true +project_fields = [ + { name = "release_tag", type = "string", nullable = false, approval_renderable = true }, + { name = "target_commit", type = "string", nullable = false, approval_renderable = true }, + { name = "release_version", type = "string", nullable = false, approval_renderable = true }, +] + +[operation_variants.authority_kind] +kind = "kent_transition_template" +workflow_id = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" +project_id = "project-6c1b6c4e-c8e2-4170-b9f7-a7d9c4602176" +approval_authority = "publish-release" + +[[operation_variants]] +key = "observe_release" +operation_kind = "observe" +authority_transitions = [] +required_job_contract_keys = ["build_contract", "detekt_contract", "unit-tests_contract"] +qualification_job_contract_keys = [] +effect_job_contract_keys = [] +approval_required = false +project_fields = [ + { name = "release_tag", type = "string", nullable = false, approval_renderable = true }, +] + +[operation_variants.authority_kind] +kind = "github_run_template" +workflow_path = ".github/workflows/release.yml" +workflow_name = "Release" +event = "push" +[operation_variants.authority_kind.ref_policy] +kind = "prefix_project_field" +prefix = "refs/tags/" +project_field = "release_tag" + +[[approval_materializations]] +variant_key = "publish_after_merge" +source_path = ".kent/scripts/workflow-puber-release-publish" +source_node_key = "publish" +source_node_kind = "script" +authority_transition_parameter = "authority_transition" +summary_language = "ru" +summary_sections = ["Нужно от вас", "Почему", "После подтверждения"] +materialized_before_pending_approval = true +commentary_equals_summary = true +decision_may_select_approval = false +required_fields = ["release_tag", "release_version", "target_commit"] +[approval_materializations.templates.publish_after_merge] +"Нужно от вас" = "Подтвердите публикацию релиза {{release_tag}} версии {{release_version}}." +"Почему" = "Только слитый коммит {{target_commit}} может быть опубликован." +"После подтверждения" = "Будет создан и отправлен только лёгкий тег {{release_tag}}." From becc765052ace035394f3f6a8760d5f99a7b91c5 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 04:45:51 +0500 Subject: [PATCH 03/21] Fix Puber release source closure --- .kent/workflows/puber-release.manifest.json | 99 +-------------------- 1 file changed, 1 insertion(+), 98 deletions(-) diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json index 53114021..42646437 100644 --- a/.kent/workflows/puber-release.manifest.json +++ b/.kent/workflows/puber-release.manifest.json @@ -5,15 +5,9 @@ "repository": "rovkinmax/Puber", "topology_kind": "puber-release", "additional_paths": [ - ".github/workflows/pr-checks.yml", - ".github/workflows/release.yml", - ".kent/commands/cleanup-task.md", - ".kent/commands/compliance-review.md", ".kent/commands/release-branch.md", ".kent/commands/release-tag.md", ".kent/commands/release.md", - ".kent/commands/ship-pr.md", - ".kent/project-contract.md", ".kent/scripts/tests/test-github-release-workflow", ".kent/scripts/tests/test-puber-release-graph", ".kent/scripts/tests/test-runtime-v2-command-closure", @@ -22,21 +16,10 @@ ".kent/scripts/tests/test-workflow-release-cleanup", ".kent/scripts/tests/test-workflow-verify-report", ".kent/scripts/tests/test-workflow-wait-github-release", - ".kent/scripts/workflow-compile-verify", - ".kent/scripts/workflow-evidence-ledger", ".kent/scripts/workflow-puber-release-intent", - ".kent/scripts/workflow-puber-release-publish", ".kent/scripts/workflow-release-cleanup", - ".kent/scripts/workflow-task-janitor", - ".kent/scripts/workflow-verify-report", - ".kent/scripts/workflow-wait-github-ci", - ".kent/scripts/workflow-wait-github-pr", ".kent/scripts/workflow-wait-github-release", - ".kent/scripts/workflow_runtime_contracts.py", - ".kent/workflows/README.md", - ".kent/workflows/builders/puber_release.py", - ".kent/workflows/puber-release.json", - ".kent/workflows/specs/puber-release.toml" + ".kent/workflows/README.md" ], "additional_trees": [], "declared_prompt_references": [], @@ -46,26 +29,6 @@ "key": "f3f8e302dc51b1e536f8fba3ddff9857f7ce46271e83cb7a63517105fb35e694", "runtime_digest_required": true }, - { - "kind": "source-sha256", - "key": ".github/workflows/pr-checks.yml=e261e6fd62de561029fc926870bfd205450ed92a3e115ca252ece8dbe63c679c", - "runtime_digest_required": true - }, - { - "kind": "source-sha256", - "key": ".github/workflows/release.yml=e7a61c83043eb79725ed03c58dedf516cdea460a0a22f6c8c85fc611b51db868", - "runtime_digest_required": true - }, - { - "kind": "source-sha256", - "key": ".kent/commands/cleanup-task.md=148470dfdb497ff1254ae69ed5c336f57bff2e05926bec83be43498d069c3ec4", - "runtime_digest_required": true - }, - { - "kind": "source-sha256", - "key": ".kent/commands/compliance-review.md=05085da2982fcfa23e51137baff494308c1cfbc6b70c2e522e4ac4002ccfe1a6", - "runtime_digest_required": true - }, { "kind": "source-sha256", "key": ".kent/commands/release-branch.md=9c7d1f8e4ba0f9140370eb29d11f6609581b300567683810a990057fd3da7dba", @@ -81,16 +44,6 @@ "key": ".kent/commands/release.md=eb130f4290800db501d93537c65344d20a6371960c6daf9ff30737daeb03d9c3", "runtime_digest_required": true }, - { - "kind": "source-sha256", - "key": ".kent/commands/ship-pr.md=ab7cb72d33ef4ddb9e96f0f45d10edfe1ef0f5a3d1b0f3c08b816c7d704007ef", - "runtime_digest_required": true - }, - { - "kind": "source-sha256", - "key": ".kent/project-contract.md=e8dfa01ad1533d9ba3612f86f7b4be19967284416f8297cc3a69bd4880025c30", - "runtime_digest_required": true - }, { "kind": "source-sha256", "key": ".kent/scripts/tests/test-github-release-workflow=0437c17d451e6bdb064b79b6c12c7a8c673fe1fa6923ee9c8d4449b737ad055e", @@ -131,75 +84,25 @@ "key": ".kent/scripts/tests/test-workflow-wait-github-release=dcbe7f3c52422776b5cdbf8d1bf8015a16da05d61a4bfed0ce637dfa7c9f1063", "runtime_digest_required": true }, - { - "kind": "source-sha256", - "key": ".kent/scripts/workflow-compile-verify=b5c78573399a6b741b71c09558e1d52448f8f355bcefb997a8e1cc5db2639c1c", - "runtime_digest_required": true - }, - { - "kind": "source-sha256", - "key": ".kent/scripts/workflow-evidence-ledger=82c65058924216b55257fdd74aa23015eeff6720f2b339be67cd134c20cd3a5b", - "runtime_digest_required": true - }, { "kind": "source-sha256", "key": ".kent/scripts/workflow-puber-release-intent=afdd4f7b22e2174f6efb3c9ebd242d659c544e7a635b4bbbde045b92433356d5", "runtime_digest_required": true }, - { - "kind": "source-sha256", - "key": ".kent/scripts/workflow-puber-release-publish=3eb30e55722a9dbb17fc1444cfd014c4497de02427854c2f4cded585deb4ea5e", - "runtime_digest_required": true - }, { "kind": "source-sha256", "key": ".kent/scripts/workflow-release-cleanup=294e052859c038ef34d6f5d1f99b9b2f62a5e389e7403de73ee747d04fd08a01", "runtime_digest_required": true }, - { - "kind": "source-sha256", - "key": ".kent/scripts/workflow-task-janitor=6b14f27a8df0f8cd1e002640564dcf20ad733f67d307ec382e5dee3710d550ba", - "runtime_digest_required": true - }, - { - "kind": "source-sha256", - "key": ".kent/scripts/workflow-verify-report=c1562e16021ec51652e963eb981db8e3d2d1530b78a20d28966de10510405e11", - "runtime_digest_required": true - }, - { - "kind": "source-sha256", - "key": ".kent/scripts/workflow-wait-github-ci=5e272b3a32200df034df0be5d76451853f26b0f0f58838d0b6e9fe7fc8a1c5d4", - "runtime_digest_required": true - }, - { - "kind": "source-sha256", - "key": ".kent/scripts/workflow-wait-github-pr=e16fe3b654a24168d4850c98d515cd28f9ce251329bc57b406d30dd012b599f4", - "runtime_digest_required": true - }, { "kind": "source-sha256", "key": ".kent/scripts/workflow-wait-github-release=738f718a625137a9bfeae6de8b42e150f0ca71556ee0d9f58011169e73e218d7", "runtime_digest_required": true }, - { - "kind": "source-sha256", - "key": ".kent/scripts/workflow_runtime_contracts.py=0dd46123287c9238c8e003236ca99cce0ee719ef4003f28dbc77fdae033b209d", - "runtime_digest_required": true - }, { "kind": "source-sha256", "key": ".kent/workflows/README.md=c7dead4ab4f73a0ebc095ad1838bdf557c4c0b3df007578e05ea78f76bf23de2", "runtime_digest_required": true - }, - { - "kind": "source-sha256", - "key": ".kent/workflows/puber-release.json=441739e4270ec9a5e3fc8fbd6c123e4e142ced3eb7f234a7f074045598d1f363", - "runtime_digest_required": true - }, - { - "kind": "source-sha256", - "key": ".kent/workflows/specs/puber-release.toml=9867df0971eba6c8f30b52f4a37ea3144e1ef5ac08ab1e507790503ef6d9c58c", - "runtime_digest_required": true } ], "runtime_attested": false From 8aef6b2a396c383f969842e81d97bad9bc1f546c Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 05:14:50 +0500 Subject: [PATCH 04/21] fix: harden puber release runtime contracts --- .kent/scripts/tests/test-puber-release-graph | 6 + .../tests/test-runtime-v2-command-closure | 1 + .../tests/test-workflow-puber-release-intent | 45 +- .../tests/test-workflow-puber-release-publish | 35 +- .../tests/test-workflow-release-cleanup | 25 +- .../tests/test-workflow-wait-github-release | 40 +- .kent/scripts/workflow-puber-release-intent | 425 ++++++++++++++---- .kent/scripts/workflow-puber-release-publish | 125 ++---- .kent/scripts/workflow-release-cleanup | 89 +--- .kent/scripts/workflow-wait-github-release | 133 ++---- .kent/workflows/builders/puber_release.py | 107 +++++ .kent/workflows/puber-release.json | 185 ++++++++ .kent/workflows/puber-release.manifest.json | 20 +- .kent/workflows/specs/puber-release.toml | 8 +- 14 files changed, 885 insertions(+), 359 deletions(-) mode change 100755 => 100644 .kent/scripts/tests/test-workflow-puber-release-intent mode change 100755 => 100644 .kent/scripts/tests/test-workflow-puber-release-publish mode change 100755 => 100644 .kent/scripts/tests/test-workflow-release-cleanup mode change 100755 => 100644 .kent/scripts/tests/test-workflow-wait-github-release diff --git a/.kent/scripts/tests/test-puber-release-graph b/.kent/scripts/tests/test-puber-release-graph index 87b757e3..178809b4 100755 --- a/.kent/scripts/tests/test-puber-release-graph +++ b/.kent/scripts/tests/test-puber-release-graph @@ -17,4 +17,10 @@ bad=copy.deepcopy(source); next(n for n in bad["nodes"] if n["key"]=="publish")[ assert run(bad).returncode != 0 bad=copy.deepcopy(source); publish=next(n["id"] for n in bad["nodes"] if n["key"]=="publish"); next(e for e in bad["edges"] if e.get("target_node_id")==publish)["requires_approval"]=False assert run(bad).returncode != 0 +bad=copy.deepcopy(source); next(e for e in bad["edges"] if e["key"]=="monitor_release")["parameters"].pop() +assert run(bad).returncode != 0 +bad=copy.deepcopy(source); next(e for e in bad["edges"] if e["key"]=="publish_needs_user_action")["parameters"].append({"key":"stale"}) +assert run(bad).returncode != 0 +bad=copy.deepcopy(source); next(e for e in bad["edges"] if e["key"]=="release_release_published")["target_node_id"]=next(n["id"] for n in bad["nodes"] if n["key"]=="publish") +assert run(bad).returncode != 0 print("puber-release-graph: passed") diff --git a/.kent/scripts/tests/test-runtime-v2-command-closure b/.kent/scripts/tests/test-runtime-v2-command-closure index a524128c..cd260202 100755 --- a/.kent/scripts/tests/test-runtime-v2-command-closure +++ b/.kent/scripts/tests/test-runtime-v2-command-closure @@ -3,6 +3,7 @@ from pathlib import Path import hashlib, subprocess ROOT=Path(__file__).resolve().parents[3] KIT=Path("/Users/rovkinmax/.kent/worktrees/kent-engineering-kit/s02a-kit-profile") +assert KIT.is_dir() and subprocess.run(["git","rev-parse","HEAD"],cwd=KIT,text=True,capture_output=True).stdout.strip()=="349267977781ce0cf6991f96c4e738b594cda4d4" pairs=[(".kent/scripts/workflow_runtime_contracts.py","workflowkit/runtime.py"),(".kent/scripts/workflow-verify-report","templates/project/workflow-verify-report"),(".kent/scripts/workflow-evidence-ledger","templates/project/workflow-evidence-ledger"),(".kent/scripts/workflow-task-janitor","templates/project/workflow-task-janitor"),(".kent/scripts/workflow-wait-github-pr","templates/project/workflow-wait-github-pr"),(".kent/scripts/workflow-wait-github-ci","templates/project/workflow-wait-github-ci")] for target,source in pairs: assert hashlib.sha256((ROOT/target).read_bytes()).digest()==hashlib.sha256((KIT/source).read_bytes()).digest(), target profile=(ROOT/'.kent/workflow-profile.toml').read_text(); assert 'runtime_contracts = "2.0.0"' in profile and 'topology_kind = "puber-release"' in profile diff --git a/.kent/scripts/tests/test-workflow-puber-release-intent b/.kent/scripts/tests/test-workflow-puber-release-intent old mode 100755 new mode 100644 index 128f6b67..f7d4c080 --- a/.kent/scripts/tests/test-workflow-puber-release-intent +++ b/.kent/scripts/tests/test-workflow-puber-release-intent @@ -1,6 +1,43 @@ #!/usr/bin/env python3 -import json, subprocess +from __future__ import annotations +import importlib.util +from importlib.machinery import SourceFileLoader from pathlib import Path -ROOT=Path(__file__).resolve().parents[3] -r=subprocess.run([str(ROOT/'.kent/scripts/workflow-puber-release-intent')],cwd=ROOT,input=json.dumps({'workspace_path':str(ROOT)}),text=True,capture_output=True) -out=json.loads(r.stdout); assert out['transition']=='release_intent_blocked'; assert 'authority' in out['blocker_reason']; print('workflow-puber-release-intent: passed') + +ROOT = Path(__file__).resolve().parents[3] +path = ROOT / ".kent/scripts/workflow-puber-release-intent" +spec = importlib.util.spec_from_loader("intent_runtime_test", SourceFileLoader("intent_runtime_test", str(path))) +mod = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(mod) + +node_id = mod._node_id(ROOT, "release_intent_gate") +mod.validate_task = lambda root, task_id, node_key, task=None: { + "task": {"body": "release_type: patch", "live_sessions": [{"session_id": "session-1"}]}, + "task_id": task_id, "task_short_id": "PUBER-1", "node_id": node_id, + "commit": "a" * 40, +} +base = {"_kent": {"task_id": "task-12345678-1234-4234-8234-123456789abc", "node_id": node_id, "transition_branch_key": "start_release_intent_gate"}} +_, state, edge = mod.validate_envelope(base, ROOT, "release_intent_gate") +assert edge["key"] == "start_release_intent_gate" and state["task_short_id"] == "PUBER-1" +for bad in ( + {**base, "_kent": {**base["_kent"], "extra": "x"}}, + {**base, "stale": "field"}, + {**base, "_kent": {**base["_kent"], "node_id": "wrong"}}, +): + try: + mod.validate_envelope(bad, ROOT, "release_intent_gate") + except mod.ContractError: + pass + else: + raise AssertionError("invalid zero-argument authority was accepted") +old = mod.os.environ.pop("KENT_ENGINEERING_KIT_ROOT", None) +try: + try: + mod._kit_modules() + except mod.ContractError as error: + assert "KENT_ENGINEERING_KIT_ROOT" in str(error) + else: + raise AssertionError("unsealed Kit runtime was accepted") +finally: + if old is not None: + mod.os.environ["KENT_ENGINEERING_KIT_ROOT"] = old +print("workflow-puber-release-intent: passed") diff --git a/.kent/scripts/tests/test-workflow-puber-release-publish b/.kent/scripts/tests/test-workflow-puber-release-publish old mode 100755 new mode 100644 index 40cc0db9..1ebb8f6c --- a/.kent/scripts/tests/test-workflow-puber-release-publish +++ b/.kent/scripts/tests/test-workflow-puber-release-publish @@ -1,5 +1,34 @@ #!/usr/bin/env python3 -import json,subprocess +from __future__ import annotations +import importlib.util +from importlib.machinery import SourceFileLoader from pathlib import Path -ROOT=Path(__file__).resolve().parents[3] -r=subprocess.run([str(ROOT/'.kent/scripts/workflow-puber-release-publish')],cwd=ROOT,input=json.dumps({'workspace_path':str(ROOT),'release_tag':'v99.0.0','target_commit':'deadbeef'}),text=True,capture_output=True); out=json.loads(r.stdout); assert out['transition']=='publish_blocked'; assert out['no_live_apply']; print('workflow-puber-release-publish: passed') +from types import SimpleNamespace + +ROOT = Path(__file__).resolve().parents[3] +spec = importlib.util.spec_from_loader("publish_test", SourceFileLoader("publish_test", str(ROOT / ".kent/scripts/workflow-puber-release-publish"))) +mod = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(mod) +node_id = mod.helper._node_id(ROOT, "publish") +payload = {"_kent": {"task_id": "task-12345678-1234-4234-8234-123456789abc", "node_id": node_id, "transition_branch_key": "merge_watch_pr_merged"}, + "workspace_path": str(ROOT), "operation_id": "op-1", "pr_url": "https://github.com/rovkinmax/Puber/pull/7", + "branch_name": "release/1.2.3", "merge_strategy": "auto", "pr_head_oid": "b"*40, "pr_base_oid": "c"*40} +state = {"task": {"body": "release_version: 1.2.3\nrelease_tag: v1.2.3"}, "task_short_id": "PUBER-1", "commit": "a"*40} +edge = {"key": "merge_watch_pr_merged"} +mod.helper.read_zero_argument = lambda: payload +mod.helper.validate_envelope = lambda value, root, node: (value, state, edge) +mod.helper.canonical_pr_url = lambda value: value +mod.helper.pr_state = lambda root, url: {"headRefName":"release/1.2.3","headRefOid":"b"*40,"baseRefOid":"c"*40,"mergeCommit":{"oid":"d"*40}} +mod.helper.remote_master = lambda root: "d"*40 +mod.helper.git = lambda *args, **kwargs: "d"*40 +mod.helper.attest = lambda *args, **kwargs: {"operation": SimpleNamespace(operation_digest="op-digest")} +mod.helper.notes_report = lambda *args: {"path": ".todo/PUBER-1/release-notes-ru.md"} +mod.helper.publish_tag = lambda *args: "created_and_pushed" +seen = [] +mod.helper.emit = lambda transition, **fields: seen.append((transition, fields)) +assert mod.main() == 0 and seen[0][0] == "monitor_release" and seen[0][1]["tag_push_status"] == "created_and_pushed" + +seen.clear() +mod.helper.publish_tag = lambda *args: (_ for _ in ()).throw(mod.helper.ContractError("conflicting remote tag")) +assert mod.main() == 0 and seen[0][0] == "publish_needs_user_action" +assert seen[0][1]["target_commit"] == "d"*40 and "conflicting remote tag" in seen[0][1]["blocker_reason"] +print("workflow-puber-release-publish: passed") diff --git a/.kent/scripts/tests/test-workflow-release-cleanup b/.kent/scripts/tests/test-workflow-release-cleanup old mode 100755 new mode 100644 index c20308a6..0ed88b6f --- a/.kent/scripts/tests/test-workflow-release-cleanup +++ b/.kent/scripts/tests/test-workflow-release-cleanup @@ -1,5 +1,24 @@ #!/usr/bin/env python3 -import json,subprocess +from __future__ import annotations +import importlib.util, json +from importlib.machinery import SourceFileLoader from pathlib import Path -ROOT=Path(__file__).resolve().parents[3] -r=subprocess.run([str(ROOT/'.kent/scripts/workflow-release-cleanup')],cwd=ROOT,input=json.dumps({'workspace_path':str(ROOT)}),text=True,capture_output=True); out=json.loads(r.stdout); assert out['transition']=='cleanup_blocked'; print('workflow-release-cleanup: passed') +from types import SimpleNamespace + +ROOT = Path(__file__).resolve().parents[3] +spec = importlib.util.spec_from_loader("cleanup_test", SourceFileLoader("cleanup_test", str(ROOT / ".kent/scripts/workflow-release-cleanup"))) +mod = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(mod) +node_id = mod.helper._node_id(ROOT, "cleanup") +payload = {"_kent":{"task_id":"task-12345678-1234-4234-8234-123456789abc","node_id":node_id,"transition_branch_key":"release_release_published"}, + "release_report":"release-report","publication_report":"publication-report","release_notes_path":"notes.md","release_run":"run"} +state={"task":{"body":"release_branch: release/1.2.3","live_sessions":[{"session_id":"session-1"}]},"task_short_id":"PUBER-1","commit":"a"*40} +mod.helper.read_zero_argument=lambda: payload +mod.helper.validate_envelope=lambda value,root,node:(value,state,{"key":"release_release_published"}) +mod.helper.attest=lambda *args,**kwargs: {"binding":SimpleNamespace(provenance_fingerprint="cleanup-op")} +mod.helper.git=lambda *args,**kwargs: "release/1.2.3" +seen=[]; mod.helper.emit=lambda transition,**fields: seen.append((transition,fields)) +assert mod.main()==0 and seen[0][0]=="cleanup_task_janitor" and seen[0][1]["cleanup_mode"]=="report_only" +state["task"]["live_sessions"]=[{"session_id":"one"},{"session_id":"two"}]; seen.clear() +assert mod.main()==0 and seen[0][0]=="cleanup_task_janitor" and seen[0][1]["cleanup_session_id"]=="" +assert json.loads(seen[0][1]["cleanup_report"])["destructive_action"] is False +print("workflow-release-cleanup: passed") diff --git a/.kent/scripts/tests/test-workflow-wait-github-release b/.kent/scripts/tests/test-workflow-wait-github-release old mode 100755 new mode 100644 index 9efd6d6e..6b5ebae5 --- a/.kent/scripts/tests/test-workflow-wait-github-release +++ b/.kent/scripts/tests/test-workflow-wait-github-release @@ -1,5 +1,39 @@ #!/usr/bin/env python3 -import json,subprocess +from __future__ import annotations +import importlib.util, json, tempfile +from importlib.machinery import SourceFileLoader from pathlib import Path -ROOT=Path(__file__).resolve().parents[3] -r=subprocess.run([str(ROOT/'.kent/scripts/workflow-wait-github-release')],cwd=ROOT,input=json.dumps({'release_tag':'v1.2.3','target_commit':'a'*40}),text=True,capture_output=True); out=json.loads(r.stdout); assert out['transition']=='release_watch_blocked'; print('workflow-wait-github-release: passed') +from types import SimpleNamespace +import subprocess + +ROOT = Path(__file__).resolve().parents[3] +spec = importlib.util.spec_from_loader("monitor_test", SourceFileLoader("monitor_test", str(ROOT / ".kent/scripts/workflow-wait-github-release"))) +mod = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(mod) +with tempfile.TemporaryDirectory() as directory: + root = Path(directory); mod.ROOT = root; mod.helper.ROOT = root + notes = root / "release-notes.md"; notes.write_text("puber_release_notes_v1\n\n# Изменения\n\n- test\n", encoding="utf-8") + target, tag = "a"*40, "v1.2.3" + run = {"repository":"rovkinmax/Puber","workflow_path":".github/workflows/release.yml","workflow_name":"Release","event":"push","run_id":42,"attempt":1,"head_sha":target,"ref":f"refs/tags/{tag}"} + mod.helper.read_zero_argument = lambda: {"release_tag":tag,"target_commit":target,"release_notes_path":"release-notes.md","release_version":"1.2.3","pr_url":"","tag_push_status":"created_and_pushed","publication_report":"{}"} + mod.helper.validate_envelope = lambda value, root, node: (value, {"task_short_id":"PUBER-1","commit":target,"task":{"body":""}}, {"key":"monitor_release"}) + actual_run_tuple = mod.run_tuple + mod.run_tuple = lambda payload, t, target: run + mod.helper.attest = lambda *args, **kwargs: {"operation": SimpleNamespace(operation_digest="observe")} + release = {"tagName":tag,"targetCommitish":target,"name":"Puber "+tag,"isDraft":False,"isPrerelease":False, + "assets":[{"name":"puber-v1.2.3.apk"},{"name":"puber-v1.2.3.apk.sha256"}],"body":notes.read_text(),"url":"https://github.com/rovkinmax/Puber/releases/tag/"+tag} + def fake_gh(root, args, input_text=None): + if args[0:2] == ["run","view"]: + return subprocess.CompletedProcess(args, 0, json.dumps({"databaseId":42,"attempt":1,"workflowPath":".github/workflows/release.yml","headSha":target,"status":"completed","conclusion":"success","jobs":[{"status":"completed","conclusion":"success"}]}), "") + if args[0:2] == ["release","view"]: + return subprocess.CompletedProcess(args, 0, json.dumps(release), "") + raise AssertionError(args) + mod.helper.gh = fake_gh + seen=[]; mod.helper.emit=lambda transition, **fields: seen.append((transition,fields)) + assert mod.main() == 0 and seen[0][0] == "release_release_published" + try: + actual_run_tuple({"release_run": {**run, "head_sha":"b"*40}}, tag, target) + except mod.helper.ContractError: + pass + else: + raise AssertionError("switched release run was accepted") +print("workflow-wait-github-release: passed") diff --git a/.kent/scripts/workflow-puber-release-intent b/.kent/scripts/workflow-puber-release-intent index 613ffb8c..5bcbcf2d 100755 --- a/.kent/scripts/workflow-puber-release-intent +++ b/.kent/scripts/workflow-puber-release-intent @@ -1,108 +1,349 @@ #!/usr/bin/env python3 +"""Fail-closed Puber release runtime shared by all S05 Script nodes.""" from __future__ import annotations -import json, os, re, subprocess, sys +import hashlib, importlib, json, os, re, shutil, subprocess, sys, tempfile from pathlib import Path +from typing import Any, Mapping +from urllib.parse import urlsplit -SCHEMA_VERSION = 4 +sys.dont_write_bytecode = True for _name in ("KENT_SESSION_ID", "KENT_RUN_ID", "KENT_STEP_ID"): os.environ.pop(_name, None) +ROOT = Path(__file__).resolve().parents[2] +KIT_COMMIT = "349267977781ce0cf6991f96c4e738b594cda4d4" +PROJECT_ID = "project-6c1b6c4e-c8e2-4170-b9f7-a7d9c4602176" WORKFLOW_ID = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" -NODE = 'release_intent_gate' -EXPECTED_TRANSITION = "start_release_intent_gate" -KENT_KEYS = {"task_short_id", "project_id", "workflow_id", "workflow_revision", "current_node_key", "execution_commit", "worktree_path", "incoming_transition", "authority_transition"} -class ContractError(ValueError): pass -def read_object(): - raw=sys.stdin.read() - if not raw.strip(): raise ContractError("stdin must contain exactly one JSON object") - try: value=json.loads(raw) - except json.JSONDecodeError as e: raise ContractError(f"stdin is invalid JSON: {e}") from e - if not isinstance(value,dict): raise ContractError("stdin must contain one JSON object") - if os.environ.get("KENT_SESSION_ID") or os.environ.get("KENT_RUN_ID") or os.environ.get("KENT_STEP_ID"): - raise ContractError("inherited Kent identity must be cleared before child reads") - return value -def required(p,k): - v=p.get(k) - if not isinstance(v,str) or not v.strip(): raise ContractError(f"{k} must be a non-empty string") - return v.strip() -def authority(p): - a=p.get("_kent") - if not isinstance(a,dict) or set(a)!=KENT_KEYS: raise ContractError("_kent authority shape is missing, extra, or stale") - if a["workflow_id"]!=WORKFLOW_ID or a["workflow_revision"]!=88 or a["current_node_key"]!=NODE or a["incoming_transition"]!=EXPECTED_TRANSITION: - raise ContractError("_kent authority does not match this node and revision") - for k in ("task_short_id","project_id","execution_commit","worktree_path","authority_transition"): - required(a,k) - return a -def operation(p): - a=authority(p); return {"workflow_id":WORKFLOW_ID,"workflow_revision":88,"node_key":NODE,"task_short_id":a["task_short_id"],"authority_transition":a["authority_transition"]} -def emit(transition, **fields): - print(json.dumps({"schema_version":SCHEMA_VERSION,"transition":transition,**fields},ensure_ascii=False,sort_keys=True)) -def root(path): - r=Path(path).expanduser().resolve() - if not r.is_dir(): raise ContractError(f"workspace does not exist: {r}") - result=subprocess.run(["git","rev-parse","--show-toplevel"],cwd=r,text=True,capture_output=True,check=False) - if result.returncode or Path(result.stdout.strip()).resolve()!=r: raise ContractError("workspace must be the exact repository root") - return r -def git(r,*args): - x=subprocess.run(["git",*args],cwd=r,text=True,capture_output=True,check=False) - if x.returncode: raise ContractError(x.stderr.strip() or x.stdout.strip() or "git failed") - return x.stdout.strip() - -VERSION = re.compile(r"currentVersion\s*=\s*[\'\"](?P\d+\.\d+\.\d+)[\'\"]") +WORKFLOW_NAME = "Puber Release" +WORKFLOW_REVISION = 88 +SCRIPT_PATHS = { + "release_intent_gate": ".kent/scripts/workflow-puber-release-intent", + "publish": ".kent/scripts/workflow-puber-release-publish", + "monitor": ".kent/scripts/workflow-wait-github-release", + "cleanup": ".kent/scripts/workflow-release-cleanup", +} +TASK_ROOT_KEYS = {"actions","attention_count","body","current_nodes","current_scripts","execution_target","label_ids","live_sessions","project","retained_session_count","source_workspace","status","summary","workflow","worktree_path"} +KENT_KEYS = {"task_id","node_id","transition_branch_key"} +SHA1 = re.compile(r"^[0-9a-f]{40}$") +TASK_ID = re.compile(r"^task-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$") SEMVER = re.compile(r"^\d+\.\d+\.\d+$") -def next_version(current, release_type): - major, minor, patch = (int(part) for part in current.split(".")) - if release_type == "major": return f"{major+1}.0.0" - if release_type == "patch": return f"{major}.{minor}.{patch+1}" - return f"{major}.{minor+1}.0" + +class ContractError(ValueError): + pass + +def _json_one(raw: bytes) -> dict[str, Any]: + if not raw or len(raw) > 8 * 1024 * 1024: + raise ContractError("stdin must contain exactly one bounded JSON object") + try: + value = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ContractError(f"invalid JSON input: {error}") from error + if not isinstance(value, dict): + raise ContractError("stdin must contain one JSON object") + return value + +def read_zero_argument() -> dict[str, Any]: + return _json_one(sys.stdin.buffer.read()) + +def require_string(value: Any, label: str, *, empty: bool = False) -> str: + if not isinstance(value, str) or (not empty and not value.strip()): + raise ContractError(f"{label} must be a non-empty string") + return value if empty else value.strip() + +def sha256_bytes(raw: bytes) -> str: + return hashlib.sha256(raw).hexdigest() + +def child_env() -> dict[str, str]: + env = dict(os.environ) + for key in ("KENT_SESSION_ID", "KENT_RUN_ID", "KENT_STEP_ID"): + env.pop(key, None) + return env + +def run(command: list[str], *, cwd: Path, env: Mapping[str, str] | None = None, check: bool = True, input_text: str | None = None) -> subprocess.CompletedProcess[str]: + result = subprocess.run(command, cwd=cwd, env=dict(env) if env is not None else None, input=input_text, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if check and result.returncode != 0: + raise ContractError(result.stderr.strip() or result.stdout.strip() or f"command failed: {' '.join(command)}") + return result + +def git(root: Path, *args: str, check: bool = True) -> str: + return run(["git", *args], cwd=root, check=check).stdout.strip() + +def _node_id(root: Path, node_key: str) -> str: + graph = json.loads((root / ".kent/workflows/puber-release.json").read_text(encoding="utf-8")) + for node in graph.get("nodes", []): + if node.get("key") == node_key: + return require_string(node.get("id"), "graph node id") + raise ContractError(f"graph node missing: {node_key}") + +def graph_edge(root: Path, branch: str, node_key: str) -> dict[str, Any]: + graph = json.loads((root / ".kent/workflows/puber-release.json").read_text(encoding="utf-8")) + node_id = _node_id(root, node_key) + matches = [edge for edge in graph.get("edges", []) if edge.get("key") == branch and edge.get("target_node_id") == node_id] + if len(matches) != 1 or matches[0].get("workflow_id") != WORKFLOW_ID: + raise ContractError(f"incoming transition is not an exact graph edge: {branch}") + return matches[0] + +def _kent_bin(root: Path) -> str: + candidate = os.environ.get("PUBER_KENT_BIN", "") or shutil.which("kent") or str(Path.home() / ".kent/bin/kent") + if not Path(candidate).is_file() or not os.access(candidate, os.X_OK): + raise ContractError("stock Kent executable is unavailable") + return candidate + +def task_show(root: Path, task_id: str) -> dict[str, Any]: + result = run([_kent_bin(root), "task", "show", task_id, "--project", PROJECT_ID, "--json"], cwd=root, env=child_env(), check=False) + if result.returncode != 0: + raise ContractError("Kent task show failed") + return _json_one(result.stdout.encode("utf-8")) + +def validate_task(root: Path, task_id: str, node_key: str, task: dict[str, Any] | None = None) -> dict[str, Any]: + if not TASK_ID.fullmatch(task_id): + raise ContractError("_kent.task_id is invalid") + task = task if task is not None else task_show(root, task_id) + if set(task) != TASK_ROOT_KEYS: + raise ContractError("Kent task root fields are not the exact stock shape") + summary, workflow, status, execution = (task.get(key) for key in ("summary","workflow","status","execution_target")) + if not all(isinstance(value, dict) for value in (summary, workflow, status, execution)): + raise ContractError("Kent task state is incomplete") + node_id = _node_id(root, node_key) + if summary.get("id") != task_id or summary.get("project_id") != PROJECT_ID or summary.get("workflow_id") != WORKFLOW_ID: + raise ContractError("Kent task/project/workflow identity drifted") + if workflow.get("workflow_id") != WORKFLOW_ID or workflow.get("version") != WORKFLOW_REVISION or workflow.get("name", WORKFLOW_NAME) != WORKFLOW_NAME: + raise ContractError("Kent workflow identity or revision drifted") + if status.get("node_ids") != [node_id]: + raise ContractError("Kent current node is not the selected Script") + nodes, scripts = task.get("current_nodes"), task.get("current_scripts") + if not isinstance(nodes, list) or len(nodes) != 1 or nodes[0].get("node_id") != node_id: + raise ContractError("Kent current node state is ambiguous") + if not isinstance(scripts, list) or len(scripts) != 1 or scripts[0].get("path") != SCRIPT_PATHS[node_key] or scripts[0].get("current_node", {}).get("node_id") != node_id: + raise ContractError("Kent current Script path or node drifted") + worktree = require_string(task.get("worktree_path"), "worktree_path") + if Path(worktree).expanduser().resolve() != root: + raise ContractError("Kent task worktree is not the selected repository root") + commit = require_string(execution.get("commit_oid"), "execution_target.commit_oid") + if not SHA1.fullmatch(commit) or git(root, "rev-parse", "HEAD") != commit: + raise ContractError("selected checkout is not the exact Task execution commit") + if git(root, "status", "--porcelain=v1", "--untracked-files=all"): + raise ContractError("selected checkout is not clean") + return {"task": task, "task_id": task_id, "task_short_id": require_string(summary.get("short_id"), "task short id"), "node_id": node_id, "commit": commit} + +def validate_envelope(payload: Mapping[str, Any], root: Path, node_key: str, *, task: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + if not isinstance(payload, Mapping): + raise ContractError("Script input must be one object") + metadata = payload.get("_kent") + if not isinstance(metadata, dict) or set(metadata) != KENT_KEYS: + raise ContractError("_kent must contain exactly task_id, node_id, transition_branch_key") + task_id = require_string(metadata.get("task_id"), "_kent.task_id") + if metadata.get("node_id") != _node_id(root, node_key): + raise ContractError("_kent.node_id does not match the current Script") + branch = require_string(metadata.get("transition_branch_key"), "_kent.transition_branch_key") + edge = graph_edge(root, branch, node_key) + expected = {"_kent", *(item.get("key") for item in edge.get("parameters", []))} + if None in expected or set(payload) != expected: + raise ContractError(f"input fields do not exactly match graph edge {branch}") + return dict(payload), validate_task(root, task_id, node_key, task), edge + +def _kit_modules() -> dict[str, Any]: + configured = os.environ.get("KENT_ENGINEERING_KIT_ROOT", "") + if not configured: + raise ContractError("KENT_ENGINEERING_KIT_ROOT is required") + kit_root = Path(configured).expanduser().resolve() + if git(kit_root, "rev-parse", "HEAD") != KIT_COMMIT or git(kit_root, "status", "--porcelain=v1", "--untracked-files=all") or not (kit_root / "workflowkit").is_dir(): + raise ContractError("Kent Engineering Kit is not the exact clean published revision") + sys.path.insert(0, str(kit_root)) + importlib.invalidate_caches() + workflowkit = importlib.import_module("workflowkit") + for name in ("workflowkit", "workflowkit.release", "workflowkit.runtime"): + path = Path(importlib.import_module(name).__file__).resolve() + if kit_root not in path.parents: + raise ContractError("Kit import escaped the selected root") + return {name: getattr(workflowkit, name) for name in ("ProjectProfile","ReleaseSpec","NormalizedGitHubWorkflowSourceV1","preflight_project_revision","capture_runtime_execution_context","capture_runtime_authority_binding","validate_operation_jobs","canonicalize_publication_operation")} + +def _external_captures(root: Path, profile: Any, selected: Any) -> list[tuple[str, str, bytes]]: + captures = [] + for external in selected.external_roots: + if external.kind == "builder-sha256": + path, digest = root / profile.release.builder_path, external.key + elif external.kind == "source-sha256" and "=" in external.key: + relative, digest = external.key.split("=", 1) + path = root / relative + else: + raise ContractError("runtime external root descriptor is unsupported") + raw = path.read_bytes() + if sha256_bytes(raw) != digest: + raise ContractError(f"runtime external source drifted: {external.kind}") + captures.append((external.kind, external.key, raw)) + return captures + +def _normalized_sources(root: Path, kit: dict[str, Any], spec: Any) -> list[Any]: + rows = list(spec.required_jobs_v1.jobs) + list(spec.effect_jobs_v1.jobs) + output = [] + for path in sorted({row["workflow_path"] for row in rows}): + text = (root / path).read_text(encoding="utf-8") + selected = [row for row in rows if row["workflow_path"] == path] + if path.endswith("pr-checks.yml"): + if "name: PR Checks" not in text or "workflow_dispatch:" in text or any(command not in text for command in (":app:detektAll",":app:testProdDebugUnitTest",":app:assembleProdDebug")): + raise ContractError("PR Checks source/job identity drifted") + if "pull_request:" not in text or "branches: [ master ]" not in text or "permissions:\n contents: read" not in text: + raise ContractError("PR Checks event or permission source drifted") + name, permissions = "PR Checks", {"contents":"read"} + elif path.endswith("release.yml"): + if "name: Release" not in text or "workflow_dispatch:" in text or "--generate-notes=false" not in text or '--notes ""' not in text: + raise ContractError("Release source/job identity drifted") + if "push:" not in text or "tags:" not in text or "permissions:\n contents: write" not in text: + raise ContractError("Release event or permission source drifted") + name, permissions = "Release", {"contents":"write"} + else: + raise ContractError(f"unexpected workflow source: {path}") + jobs, events = [], [] + for row in selected: + if f" {row['job_key']}:" not in text or f"name: {row['job_display_name']}" not in text or f"runs-on: {row['runs_on']}" not in text: + raise ContractError(f"workflow job identity drifted: {row['job_key']}") + for step in row["steps"]: + if step["kind"] == "uses" and f"uses: {step['uses']}" not in text: + raise ContractError(f"workflow action drifted: {row['job_key']}:{step['name']}") + if step["kind"] == "run" and step["run"] and step["run"] not in text: + raise ContractError(f"workflow command drifted: {row['job_key']}:{step['name']}") + if step["name"] and step["name"] not in text: + raise ContractError(f"workflow step drifted: {row['job_key']}:{step['name']}") + job = {key: row[key] for key in ("job_key","job_display_name","needs","matrix","condition","continue_on_error","runs_on","runner_environment_asserted","effective_permissions","effective_defaults_run","github_environment","services","container","checkout_persist_credentials","secret_refs","effective_environment","steps")} + job["steps"] = [{key: step[key] for key in ("kind","name","condition","continue_on_error","uses","with","run","effective_shell","effective_working_directory","effective_environment","secret_refs")} for step in row["steps"]] + jobs.append(job) + if row["event_selector"] not in events: + events.append(row["event_selector"]) + output.append(kit["NormalizedGitHubWorkflowSourceV1"].from_dict({"schema":"normalized_github_workflow_source_v1","workflow_path":path,"workflow_display_name":name,"events":events,"permissions":permissions,"environment":{},"defaults_run":{"shell":"","working_directory":""},"jobs":jobs})) + return output + +def attest(root: Path, state: dict[str, Any], node_key: str, branch: str, *, kind: str | None = None, run_tuple: Mapping[str, Any] | None = None, project_fields: Mapping[str, Any] | None = None) -> dict[str, Any]: + kit = _kit_modules() + revision = kit["preflight_project_revision"](root, state["commit"]) + selected = revision.selected_runtime_source_inputs + if selected is None or selected.project_commit != state["commit"]: + raise ContractError("selected sealed RuntimeSourceInputs are missing or stale") + profile = kit["ProjectProfile"].from_toml(root, (root / ".kent/workflow-profile.toml").read_text(encoding="utf-8"), source=".kent/workflow-profile.toml", check_files=False) + spec = kit["ReleaseSpec"].from_toml((root / profile.release.spec_path).read_text(encoding="utf-8"), profile=profile) + captures = _external_captures(root, profile, selected) + if run_tuple is None: + execution = {"kind":"kent_transition","task_id":state["task_id"],"task_short_id":state["task_short_id"],"workflow_id":WORKFLOW_ID,"workflow_revision":WORKFLOW_REVISION,"project_id":PROJECT_ID,"project_commit":state["commit"],"authority_transition":branch} + authority = {"kind":"kent_transition","task_short_id":state["task_short_id"],"workflow_id":WORKFLOW_ID,"workflow_revision":WORKFLOW_REVISION,"project_id":PROJECT_ID,"approval_authority":"publish-release","authority_transition":branch} + else: + execution = {"kind":"github_run", **dict(run_tuple)} + authority = {"kind":"github_run", **dict(run_tuple)} + context = kit["capture_runtime_execution_context"](selected, execution) + binding = kit["capture_runtime_authority_binding"](selected, captures, context, authority) + canonicalized = None + if kind: + variant_key = "publish_after_merge" if kind == "publish" else "observe_release" + variant = next((item for item in spec.operation_variants if item.key == variant_key), None) + if variant is None: + raise ContractError(f"missing ReleaseSpec operation variant: {variant_key}") + jobs = kit["validate_operation_jobs"](variant, _normalized_sources(root, kit, spec), required=spec.required_jobs_v1, qualification=spec.qualification_jobs_v1, effect=spec.effect_jobs_v1) + operation = {"schema_version":1,"variant_key":variant_key,"operation_kind":variant.operation_kind,"repository":"rovkinmax/Puber","runtime_source_envelope_digest":binding.runtime_source_envelope_digest,"operation_jobs_manifest_digest":jobs.operation_jobs_manifest_digest,"authority":authority,"project_fields":dict(project_fields or {})} + canonicalized = kit["canonicalize_publication_operation"](operation, variant, jobs, spec=spec, runtime_execution_context=context, runtime_authority_binding=binding) + return {"kit":kit,"profile":profile,"spec":spec,"selected":selected,"context":context,"binding":binding,"operation":canonicalized} + +def declarations(body: Any) -> dict[str, str]: + values = {} + if isinstance(body, str): + for line in body.splitlines(): + match = re.match(r"^\s*(?:[-*]\s+)?([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*?)\s*$", line) + if match and match.group(1) in {"release_type","release_version","release_tag","release_branch"}: + key, value = match.group(1), match.group(2).strip().strip("`\"'") + if key in values and values[key] != value: + raise ContractError("Task release declarations are ambiguous") + values[key] = value + return values + +def canonical_pr_url(value: Any) -> str: + url = require_string(value, "pr_url") + parsed = urlsplit(url) + if parsed.scheme != "https" or parsed.netloc != "github.com" or parsed.query or parsed.fragment or not re.fullmatch(r"/rovkinmax/Puber/pull/[1-9][0-9]*", parsed.path): + raise ContractError("pr_url is not canonical") + return url + +def remote_master(root: Path) -> str: + value = git(root, "rev-parse", "refs/remotes/origin/master", check=False) + if not SHA1.fullmatch(value): + raise ContractError("current remote master is unavailable") + return value + +def gh(root: Path, args: list[str], *, input_text: str | None = None) -> subprocess.CompletedProcess[str]: + return run([os.environ.get("PUBER_GH_BIN", shutil.which("gh") or "gh"), *args], cwd=root, env=child_env(), check=False, input_text=input_text) + +def pr_state(root: Path, url: str) -> dict[str, Any]: + result = gh(root, ["pr","view",url,"--json","state,mergedAt,mergeCommit,headRefName,headRefOid,baseRefName,baseRefOid,isCrossRepository,url"]) + if result.returncode != 0: + raise ContractError("GitHub PR query failed") + value = _json_one(result.stdout.encode("utf-8")) + if value.get("state") != "MERGED" or not value.get("mergedAt") or value.get("baseRefName") != "master" or value.get("isCrossRepository") is not False or value.get("url") != url: + raise ContractError("PR is not exact merged same-repository master evidence") + return value + +def local_tag(root: Path, tag: str) -> str: + value = git(root, "rev-parse", f"refs/tags/{tag}^{{commit}}", check=False) + return value if SHA1.fullmatch(value) else "" + +def remote_tag(root: Path, tag: str) -> str: + output = git(root, "ls-remote", "--tags", "origin", f"refs/tags/{tag}", f"refs/tags/{tag}^{{}}", check=False) + values = [line.split()[0] for line in output.splitlines() if line.split() and SHA1.fullmatch(line.split()[0])] + return values[-1] if values else "" + +def notes_report(root: Path, task_short_id: str, tag: str, target: str) -> dict[str, Any]: + path = root / ".todo" / task_short_id / "release-notes-ru.md" + previous = next((item for item in git(root, "tag", "--sort=-v:refname", check=False).splitlines() if item != tag and re.fullmatch(r"v\d+\.\d+\.\d+", item)), "") + revision = f"{previous}..{target}" if previous else target + subjects = git(root, "log", "--format=%s", revision, check=False).splitlines()[:100] + body = ("puber_release_notes_v1\n\n# Изменения\n\n" + "\n".join(f"- {subject}" for subject in subjects) + "\n").encode("utf-8") + if path.exists(): + if path.read_bytes() != body: + raise ContractError("existing canonical release notes conflict") + else: + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(body); stream.flush(); os.fsync(stream.fileno()) + os.replace(temp, path) + finally: + Path(temp).unlink(missing_ok=True) + readback = path.read_bytes() + if readback != body or b"\r" in readback or not readback.endswith(b"\n") or not readback.decode("utf-8"): + raise ContractError("canonical notes readback failed") + return {"schema":"puber_release_notes_v1","path":str(path.relative_to(root)),"bytes":len(readback),"sha256":sha256_bytes(readback),"encoding":"UTF-8","line_endings":"LF","final_newline":True,"producer":".kent/scripts/workflow-puber-release-publish","previous_tag":previous,"range":revision,"target_commit":target} + +def publish_tag(root: Path, tag: str, target: str) -> str: + remote = remote_tag(root, tag) + if remote: + if remote != target: + raise ContractError("remote tag conflicts with exact target") + return "already_present_exact" + if local_tag(root, tag): + raise ContractError("local tag exists while remote tag is absent") + git(root, "tag", tag, target) + git(root, "push", "origin", f"refs/tags/{tag}") + if local_tag(root, tag) != target or remote_tag(root, tag) != target: + raise ContractError("tag readback mismatch") + return "created_and_pushed" + +def report_json(value: Mapping[str, Any]) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + +def emit(transition: str, **fields: Any) -> None: + print(json.dumps({"transition":transition, **fields}, ensure_ascii=False, sort_keys=True)) def main() -> int: try: - payload = read_object() - op = operation(payload) - workspace = root(str(payload.get("workspace_path") or Path.cwd())) - authority_data = authority(payload) - if authority_data["worktree_path"] != str(workspace): - raise ContractError("_kent worktree_path does not match workspace_path") - if authority_data["execution_commit"] != git(workspace, "rev-parse", "HEAD"): - raise ContractError("_kent execution_commit does not match selected revision") - - branch = git(workspace, "branch", "--show-current") - requested_branch = str(payload.get("branch_name") or branch).strip() - if requested_branch != branch: - raise ContractError(f"branch mismatch: expected {requested_branch}, found {branch}") - if not branch or branch in {"master", "main"}: - raise ContractError("release intent requires a non-default task branch") - match = VERSION.search((workspace / "app/build.gradle.kts").read_text()) - if not match: - raise ContractError("app/build.gradle.kts has no currentVersion") - current = match.group("version") - release_type = str(payload.get("release_type") or "minor").strip().lower() - if release_type not in {"minor", "patch", "major"}: - raise ContractError("release_type must be minor, patch, or major") - release_version = str(payload.get("release_version") or next_version(current, release_type)).strip() - if not SEMVER.fullmatch(release_version): - raise ContractError("release_version must be semantic version text") - release_tag = str(payload.get("release_tag") or f"v{release_version}").strip() - if release_tag != f"v{release_version}": - raise ContractError("release_tag must match release_version") - emit( - "release_intent_passed", - operation=op, - workspace_path=str(workspace), - branch_name=branch, - release_type=release_type, - current_version=current, - release_version=release_version, - release_branch=branch, - release_tag=release_tag, - target_commit=git(workspace, "rev-parse", "HEAD"), - source_revision=88, - no_live_apply=True, - ) - except (ContractError, OSError) as error: + payload, state, edge = validate_envelope(read_zero_argument(), ROOT, "release_intent_gate") + attested = attest(ROOT, state, "release_intent_gate", edge["key"]) + current = re.search(r"currentVersion\s*=\s*[\"'](\d+\.\d+\.\d+)[\"']", (ROOT / "app/build.gradle.kts").read_text(encoding="utf-8")) + if not current: raise ContractError("currentVersion is missing") + values = declarations(state["task"].get("body")); kind = values.get("release_type", "minor") + major, minor, patch = (int(item) for item in current.group(1).split(".")) + version = values.get("release_version") or (f"{major+1}.0.0" if kind == "major" else f"{major}.{minor}.{patch+1}" if kind == "patch" else f"{major}.{minor+1}.0") + tag = values.get("release_tag", f"v{version}") + if not SEMVER.fullmatch(version) or tag != f"v{version}": raise ContractError("release intent version/tag is not exact") + emit("release_intent_passed", workspace_path=str(ROOT), operation_id=attested["binding"].provenance_fingerprint, task_short_id=state["task_short_id"], release_type=kind, release_version=version, release_tag=tag, source_revision=WORKFLOW_REVISION, no_live_apply=True) + except Exception as error: emit("release_intent_blocked", blocker_reason=str(error), no_live_apply=True) return 0 - if __name__ == "__main__": raise SystemExit(main()) diff --git a/.kent/scripts/workflow-puber-release-publish b/.kent/scripts/workflow-puber-release-publish index 32252a7b..145a8668 100755 --- a/.kent/scripts/workflow-puber-release-publish +++ b/.kent/scripts/workflow-puber-release-publish @@ -1,92 +1,49 @@ #!/usr/bin/env python3 from __future__ import annotations -import json, os, re, subprocess, sys +import importlib.util +from importlib.machinery import SourceFileLoader +import sys from pathlib import Path - -SCHEMA_VERSION = 4 -for _name in ("KENT_SESSION_ID", "KENT_RUN_ID", "KENT_STEP_ID"): - os.environ.pop(_name, None) -WORKFLOW_ID = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" -NODE = 'publish' -EXPECTED_TRANSITION = 'publish_after_merge' -KENT_KEYS = {"task_short_id", "project_id", "workflow_id", "workflow_revision", "current_node_key", "execution_commit", "worktree_path", "incoming_transition", "authority_transition"} -class ContractError(ValueError): pass -def read_object(): - raw=sys.stdin.read() - if not raw.strip(): raise ContractError("stdin must contain exactly one JSON object") - try: value=json.loads(raw) - except json.JSONDecodeError as e: raise ContractError(f"stdin is invalid JSON: {e}") from e - if not isinstance(value,dict): raise ContractError("stdin must contain one JSON object") - if os.environ.get("KENT_SESSION_ID") or os.environ.get("KENT_RUN_ID") or os.environ.get("KENT_STEP_ID"): - raise ContractError("inherited Kent identity must be cleared before child reads") - return value -def required(p,k): - v=p.get(k) - if not isinstance(v,str) or not v.strip(): raise ContractError(f"{k} must be a non-empty string") - return v.strip() -def authority(p): - a=p.get("_kent") - if not isinstance(a,dict) or set(a)!=KENT_KEYS: raise ContractError("_kent authority shape is missing, extra, or stale") - if a["workflow_id"]!=WORKFLOW_ID or a["workflow_revision"]!=88 or a["current_node_key"]!=NODE or a["incoming_transition"]!=EXPECTED_TRANSITION: - raise ContractError("_kent authority does not match this node and revision") - for k in ("task_short_id","project_id","execution_commit","worktree_path","authority_transition"): - required(a,k) - return a -def operation(p): - a=authority(p); return {"workflow_id":WORKFLOW_ID,"workflow_revision":88,"node_key":NODE,"task_short_id":a["task_short_id"],"authority_transition":a["authority_transition"]} -def emit(transition, **fields): - print(json.dumps({"schema_version":SCHEMA_VERSION,"transition":transition,**fields},ensure_ascii=False,sort_keys=True)) -def root(path): - r=Path(path).expanduser().resolve() - if not r.is_dir(): raise ContractError(f"workspace does not exist: {r}") - result=subprocess.run(["git","rev-parse","--show-toplevel"],cwd=r,text=True,capture_output=True,check=False) - if result.returncode or Path(result.stdout.strip()).resolve()!=r: raise ContractError("workspace must be the exact repository root") - return r -def git(r,*args): - x=subprocess.run(["git",*args],cwd=r,text=True,capture_output=True,check=False) - if x.returncode: raise ContractError(x.stderr.strip() or x.stdout.strip() or "git failed") - return x.stdout.strip() +sys.dont_write_bytecode = True +_helper_path = Path(__file__).with_name("workflow-puber-release-intent") +spec = importlib.util.spec_from_loader("puber_release_runtime", SourceFileLoader("puber_release_runtime", str(_helper_path))); helper = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(helper) +ROOT = helper.ROOT def main() -> int: + payload = None + derived = {} try: - payload = read_object() - op = operation(payload) - workspace = root(required(payload, "workspace_path")) - authority_data = authority(payload) - if authority_data["worktree_path"] != str(workspace): - raise ContractError("_kent worktree_path does not match workspace_path") - if authority_data["execution_commit"] != git(workspace, "rev-parse", "HEAD"): - raise ContractError("_kent execution_commit does not match selected revision") - - if payload.get("incoming_transition") != "publish_after_merge": - raise ContractError("publication requires incoming transition publish_after_merge") - if payload.get("authorized") is not True: - raise ContractError("publication requires explicit authorized=true") - target = required(payload, "target_commit") - head = git(workspace, "rev-parse", "HEAD") - if target != head: - raise ContractError(f"target commit mismatch: expected {target}, found {head}") - tag = required(payload, "release_tag") - if not tag.startswith("v"): - raise ContractError("release_tag must start with v") - if git(workspace, "tag", "--list", tag): - raise ContractError(f"tag already exists locally: {tag}") - if os.environ.get("PUBER_RELEASE_TEST_MODE") == "1" or payload.get("dry_run") is True: - emit( - "publish_monitor", operation=op, release_tag=tag, - target_commit=head, tag_push_status="dry_run", no_live_apply=True, - ) + payload, state, edge = helper.validate_envelope(helper.read_zero_argument(), ROOT, "publish") + branch = edge["key"] + values = helper.declarations(state["task"].get("body")) + if branch == "publish_needs_user_action": + helper.emit("publish_needs_user_action", **{key: payload[key] for key in payload if key != "_kent"}, no_live_apply=True) return 0 - git(workspace, "tag", tag, head) - git(workspace, "push", "origin", tag) - emit( - "publish_monitor", operation=op, release_tag=tag, - target_commit=head, tag_push_status="pushed", no_live_apply=False, - ) - except (ContractError, OSError) as error: - emit("publish_blocked", blocker_reason=str(error), no_live_apply=True) + pr_url = helper.canonical_pr_url(payload["pr_url"]) + version = values.get("release_version"); tag = values.get("release_tag") or (f"v{version}" if version else "") + derived.update({"release_version": version or "", "release_tag": tag}) + pr = helper.pr_state(ROOT, pr_url) + if pr.get("headRefName") != payload["branch_name"] or pr.get("headRefOid") != payload["pr_head_oid"] or pr.get("baseRefOid") != payload["pr_base_oid"]: + raise helper.ContractError("merged PR head/base identity drifted") + merge_commit = pr.get("mergeCommit"); target = merge_commit.get("oid") if isinstance(merge_commit, dict) else merge_commit + if not isinstance(target, str) or not helper.SHA1.fullmatch(target) or helper.remote_master(ROOT) != target or helper.git(ROOT, "rev-parse", "HEAD") != target: + raise helper.ContractError("merged target is not exact current remote master and local checkout") + derived["target_commit"] = target + if not version or not helper.SEMVER.fullmatch(version) or tag != f"v{version}": + raise helper.ContractError("release version/tag is not an immutable Task declaration") + derived.update({"release_version": version, "release_tag": tag}) + attested = helper.attest(ROOT, state, "publish", branch, kind="publish", project_fields={"release_tag":tag,"target_commit":target,"release_version":version}) + notes = helper.notes_report(ROOT, state["task_short_id"], tag, target) + derived["release_notes_path"] = notes["path"] + tag_status = helper.publish_tag(ROOT, tag, target) + publication = {"schema":"puber_publication_report_v1","operation_digest":attested["operation"].operation_digest,"target_commit":target,"release_tag":tag,"tag_status":tag_status,"notes":notes,"unresolved":False} + helper.emit("monitor_release", workspace_path=str(ROOT), operation_id=attested["operation"].operation_digest, release_version=version, release_tag=tag, target_commit=target, pr_url=pr_url, tag_push_status=tag_status, release_notes_path=notes["path"], publication_report=helper.report_json(publication), no_live_apply=False) + except Exception as error: + preserved = {key: payload[key] for key in payload if key != "_kent"} if isinstance(payload, dict) else {} + preserved.update({key: value for key, value in derived.items() if value}) + for key in ("workspace_path", "operation_id", "pr_url", "branch_name", "merge_strategy", "pr_head_oid", "pr_base_oid", "release_version", "release_tag", "target_commit", "release_notes_path", "publication_report"): + preserved.setdefault(key, "") + preserved["blocker_reason"] = str(error) + helper.emit("publish_needs_user_action", **preserved, no_live_apply=True) return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +if __name__ == "__main__": raise SystemExit(main()) diff --git a/.kent/scripts/workflow-release-cleanup b/.kent/scripts/workflow-release-cleanup index 240b67c4..e8419bd4 100755 --- a/.kent/scripts/workflow-release-cleanup +++ b/.kent/scripts/workflow-release-cleanup @@ -1,78 +1,25 @@ #!/usr/bin/env python3 from __future__ import annotations -import json, os, re, subprocess, sys +import importlib.util, json +from importlib.machinery import SourceFileLoader +import sys from pathlib import Path - -SCHEMA_VERSION = 4 -for _name in ("KENT_SESSION_ID", "KENT_RUN_ID", "KENT_STEP_ID"): - os.environ.pop(_name, None) -WORKFLOW_ID = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" -NODE = 'cleanup' -EXPECTED_TRANSITION = 'publish_monitor' -KENT_KEYS = {"task_short_id", "project_id", "workflow_id", "workflow_revision", "current_node_key", "execution_commit", "worktree_path", "incoming_transition", "authority_transition"} -class ContractError(ValueError): pass -def read_object(): - raw=sys.stdin.read() - if not raw.strip(): raise ContractError("stdin must contain exactly one JSON object") - try: value=json.loads(raw) - except json.JSONDecodeError as e: raise ContractError(f"stdin is invalid JSON: {e}") from e - if not isinstance(value,dict): raise ContractError("stdin must contain one JSON object") - if os.environ.get("KENT_SESSION_ID") or os.environ.get("KENT_RUN_ID") or os.environ.get("KENT_STEP_ID"): - raise ContractError("inherited Kent identity must be cleared before child reads") - return value -def required(p,k): - v=p.get(k) - if not isinstance(v,str) or not v.strip(): raise ContractError(f"{k} must be a non-empty string") - return v.strip() -def authority(p): - a=p.get("_kent") - if not isinstance(a,dict) or set(a)!=KENT_KEYS: raise ContractError("_kent authority shape is missing, extra, or stale") - if a["workflow_id"]!=WORKFLOW_ID or a["workflow_revision"]!=88 or a["current_node_key"]!=NODE or a["incoming_transition"]!=EXPECTED_TRANSITION: - raise ContractError("_kent authority does not match this node and revision") - for k in ("task_short_id","project_id","execution_commit","worktree_path","authority_transition"): - required(a,k) - return a -def operation(p): - a=authority(p); return {"workflow_id":WORKFLOW_ID,"workflow_revision":88,"node_key":NODE,"task_short_id":a["task_short_id"],"authority_transition":a["authority_transition"]} -def emit(transition, **fields): - print(json.dumps({"schema_version":SCHEMA_VERSION,"transition":transition,**fields},ensure_ascii=False,sort_keys=True)) -def root(path): - r=Path(path).expanduser().resolve() - if not r.is_dir(): raise ContractError(f"workspace does not exist: {r}") - result=subprocess.run(["git","rev-parse","--show-toplevel"],cwd=r,text=True,capture_output=True,check=False) - if result.returncode or Path(result.stdout.strip()).resolve()!=r: raise ContractError("workspace must be the exact repository root") - return r -def git(r,*args): - x=subprocess.run(["git",*args],cwd=r,text=True,capture_output=True,check=False) - if x.returncode: raise ContractError(x.stderr.strip() or x.stdout.strip() or "git failed") - return x.stdout.strip() +sys.dont_write_bytecode = True +_helper_path = Path(__file__).with_name("workflow-puber-release-intent") +spec = importlib.util.spec_from_loader("puber_release_runtime", SourceFileLoader("puber_release_runtime", str(_helper_path))); helper = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(helper) +ROOT=helper.ROOT def main() -> int: + payload=None try: - payload = read_object() - op = operation(payload) - workspace = root(required(payload, "workspace_path")) - authority_data = authority(payload) - if authority_data["worktree_path"] != str(workspace): - raise ContractError("_kent worktree_path does not match workspace_path") - if authority_data["execution_commit"] != git(workspace, "rev-parse", "HEAD"): - raise ContractError("_kent execution_commit does not match selected revision") - - status = git(workspace, "status", "--porcelain=v1", "--untracked-files=all") - report = { - "workspace": str(workspace), - "dirty": bool(status), - "preserved": bool(status), - "destructive_action": False, - } - emit( - "cleanup_reported", operation=op, workspace_path=str(workspace), - cleanup_report=json.dumps(report, sort_keys=True), no_live_apply=True, - ) - except (ContractError, OSError) as error: - emit("cleanup_blocked", blocker_reason=str(error), no_live_apply=True) + payload,state,edge=helper.validate_envelope(helper.read_zero_argument(),ROOT,"cleanup") + attested=helper.attest(ROOT,state,"cleanup",edge["key"]) + sessions=state["task"].get("live_sessions"); ids=[item.get("session_id") or item.get("id") for item in sessions if isinstance(item,dict)] if isinstance(sessions,list) else [] + ids=[item for item in ids if isinstance(item,str) and item] + if len(ids)!=1: raise helper.ContractError("cleanup requires one exact live Kent session identity") + body=helper.declarations(state["task"].get("body")); report={"schema":"puber_cleanup_report_v1","operation_digest":attested["binding"].provenance_fingerprint,"publication_report":payload.get("publication_report",""),"release_report":payload.get("release_report",""),"release_notes_path":payload.get("release_notes_path",""),"unresolved":bool(payload.get("blocker_reason")),"destructive_action":False} + helper.emit("cleanup_task_janitor",workspace_path=str(ROOT),branch_name=body.get("release_branch",helper.git(ROOT,"branch","--show-current")),pr_url=payload.get("pr_url",""),cleanup_mode="report_only",cleanup_session_id=ids[0],task_short_id=state["task_short_id"],cleanup_report=helper.report_json(report),publication_report=payload.get("publication_report",""),release_report=payload.get("release_report",""),release_notes_path=payload.get("release_notes_path",""),no_live_apply=True) + except Exception as error: + helper.emit("cleanup_task_janitor",workspace_path=str(ROOT),branch_name=helper.git(ROOT,"branch","--show-current",check=False),pr_url=payload.get("pr_url","") if isinstance(payload,dict) else "",cleanup_mode="report_only",cleanup_session_id="",task_short_id=state["task_short_id"] if isinstance(locals().get("state"),dict) else "",cleanup_report=helper.report_json({"schema":"puber_cleanup_report_v1","unresolved":True,"destructive_action":False,"blocker_reason":str(error)}),no_live_apply=True) return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +if __name__ == "__main__": raise SystemExit(main()) diff --git a/.kent/scripts/workflow-wait-github-release b/.kent/scripts/workflow-wait-github-release index d3ba9401..49834885 100755 --- a/.kent/scripts/workflow-wait-github-release +++ b/.kent/scripts/workflow-wait-github-release @@ -1,95 +1,54 @@ #!/usr/bin/env python3 from __future__ import annotations -import json, os, re, subprocess, sys +import importlib.util, json +from importlib.machinery import SourceFileLoader +import sys from pathlib import Path +sys.dont_write_bytecode = True +_helper_path = Path(__file__).with_name("workflow-puber-release-intent") +spec = importlib.util.spec_from_loader("puber_release_runtime", SourceFileLoader("puber_release_runtime", str(_helper_path))); helper = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(helper) +ROOT = helper.ROOT -SCHEMA_VERSION = 4 -for _name in ("KENT_SESSION_ID", "KENT_RUN_ID", "KENT_STEP_ID"): - os.environ.pop(_name, None) -WORKFLOW_ID = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" -NODE = 'monitor' -EXPECTED_TRANSITION = 'publish_monitor' -KENT_KEYS = {"task_short_id", "project_id", "workflow_id", "workflow_revision", "current_node_key", "execution_commit", "worktree_path", "incoming_transition", "authority_transition"} -class ContractError(ValueError): pass -def read_object(): - raw=sys.stdin.read() - if not raw.strip(): raise ContractError("stdin must contain exactly one JSON object") - try: value=json.loads(raw) - except json.JSONDecodeError as e: raise ContractError(f"stdin is invalid JSON: {e}") from e - if not isinstance(value,dict): raise ContractError("stdin must contain one JSON object") - if os.environ.get("KENT_SESSION_ID") or os.environ.get("KENT_RUN_ID") or os.environ.get("KENT_STEP_ID"): - raise ContractError("inherited Kent identity must be cleared before child reads") - return value -def required(p,k): - v=p.get(k) - if not isinstance(v,str) or not v.strip(): raise ContractError(f"{k} must be a non-empty string") - return v.strip() -def authority(p): - a=p.get("_kent") - if not isinstance(a,dict) or set(a)!=KENT_KEYS: raise ContractError("_kent authority shape is missing, extra, or stale") - if a["workflow_id"]!=WORKFLOW_ID or a["workflow_revision"]!=88 or a["current_node_key"]!=NODE or a["incoming_transition"]!=EXPECTED_TRANSITION: - raise ContractError("_kent authority does not match this node and revision") - for k in ("task_short_id","project_id","execution_commit","worktree_path","authority_transition"): - required(a,k) - return a -def operation(p): - a=authority(p); return {"workflow_id":WORKFLOW_ID,"workflow_revision":88,"node_key":NODE,"task_short_id":a["task_short_id"],"authority_transition":a["authority_transition"]} -def emit(transition, **fields): - print(json.dumps({"schema_version":SCHEMA_VERSION,"transition":transition,**fields},ensure_ascii=False,sort_keys=True)) -def root(path): - r=Path(path).expanduser().resolve() - if not r.is_dir(): raise ContractError(f"workspace does not exist: {r}") - result=subprocess.run(["git","rev-parse","--show-toplevel"],cwd=r,text=True,capture_output=True,check=False) - if result.returncode or Path(result.stdout.strip()).resolve()!=r: raise ContractError("workspace must be the exact repository root") - return r -def git(r,*args): - x=subprocess.run(["git",*args],cwd=r,text=True,capture_output=True,check=False) - if x.returncode: raise ContractError(x.stderr.strip() or x.stdout.strip() or "git failed") - return x.stdout.strip() +def run_tuple(payload, tag, target): + existing = payload.get("release_run") + if existing is None: + result = helper.gh(ROOT, ["run","list","--workflow",".github/workflows/release.yml","--event","push","--json","databaseId,attempt,headSha,ref,event,workflowName,workflowPath"]) + if result.returncode != 0: raise helper.ContractError("release run discovery failed") + rows = json.loads(result.stdout or "[]") + matches = [row for row in rows if isinstance(row,dict) and row.get("event")=="push" and row.get("workflowName")=="Release" and row.get("workflowPath")==".github/workflows/release.yml" and row.get("headSha")==target and row.get("ref")==f"refs/tags/{tag}"] + if len(matches) != 1: raise helper.ContractError("release run is missing or ambiguous") + row = matches[0]; existing = {"repository":"rovkinmax/Puber","workflow_path":".github/workflows/release.yml","workflow_name":"Release","event":"push","run_id":row.get("databaseId"),"attempt":row.get("attempt"),"head_sha":row.get("headSha"),"ref":row.get("ref")} + if not isinstance(existing,dict) or set(existing)!={"repository","workflow_path","workflow_name","event","run_id","attempt","head_sha","ref"} or existing["repository"]!="rovkinmax/Puber" or existing["workflow_path"]!=".github/workflows/release.yml" or existing["workflow_name"]!="Release" or existing["event"]!="push" or existing["head_sha"]!=target or existing["ref"]!=f"refs/tags/{tag}" or not isinstance(existing["run_id"],int) or existing["run_id"]<=0 or not isinstance(existing["attempt"],int) or existing["attempt"]<=0: + raise helper.ContractError("release run identity is foreign, stale, or switched") + return existing def main() -> int: + payload = None try: - payload = read_object() - op = operation(payload) - workspace = root(required(payload, "workspace_path")) - authority_data = authority(payload) - if authority_data["worktree_path"] != str(workspace): - raise ContractError("_kent worktree_path does not match workspace_path") - if authority_data["execution_commit"] != git(workspace, "rev-parse", "HEAD"): - raise ContractError("_kent execution_commit does not match selected revision") - run_tuple = payload.get("release_run") - if not isinstance(run_tuple, dict) or set(run_tuple) != {"repository", "workflow_path", "workflow_name", "event", "run_id", "attempt", "head_sha", "ref"}: - raise ContractError("release_run must be one immutable exact GitHub run tuple") - if run_tuple["repository"] != "rovkinmax/Puber" or run_tuple["workflow_path"] != ".github/workflows/release.yml" or run_tuple["workflow_name"] != "Release" or run_tuple["event"] != "push": - raise ContractError("release_run workflow identity drifted") - tag = required(payload, "release_tag") - target = required(payload, "target_commit") - if not tag.startswith("v"): - raise ContractError("release_tag must start with v") - fixture = payload.get("release_state") - if os.environ.get("PUBER_RELEASE_TEST_MODE") == "1" or fixture is not None: - state = fixture if isinstance(fixture, dict) else {"tag": tag, "target_commit": target, "published": False} - emit( - "release_watch_reported", operation=op, release_tag=tag, - target_commit=target, release_report=json.dumps(state, sort_keys=True), - no_live_apply=True, - ) - return 0 - result = subprocess.run( - ["gh", "release", "view", tag, "--json", - "tagName,targetCommitish,isDraft,isPrerelease,url"], - text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, - ) - if result.returncode != 0: - raise ContractError(result.stderr.strip() or "GitHub Release lookup failed") - emit( - "release_watch_reported", operation=op, release_tag=tag, - target_commit=target, release_report=result.stdout.strip(), no_live_apply=True, - ) - except (ContractError, OSError) as error: - emit("release_watch_blocked", blocker_reason=str(error), no_live_apply=True) + payload, state, edge = helper.validate_envelope(helper.read_zero_argument(), ROOT, "monitor") + tag = helper.require_string(payload["release_tag"], "release_tag"); target = helper.require_string(payload["target_commit"], "target_commit"); notes_path = helper.require_string(payload["release_notes_path"], "release_notes_path") + notes_bytes = (ROOT / notes_path).read_bytes(); notes_digest = helper.sha256_bytes(notes_bytes) + run = run_tuple(payload, tag, target) + attested = helper.attest(ROOT, state, "monitor", edge["key"], kind="observe_release", run_tuple=run, project_fields={"release_tag":tag}) + checked = helper.gh(ROOT, ["run","view",str(run["run_id"]),"--attempt",str(run["attempt"]),"--json","databaseId,attempt,workflowName,workflowPath,event,headSha,ref,status,conclusion,jobs"]) + if checked.returncode != 0: raise helper.ContractError("exact release run read failed") + run_state=json.loads(checked.stdout) + if run_state.get("databaseId")!=run["run_id"] or run_state.get("attempt")!=run["attempt"] or run_state.get("workflowPath")!=".github/workflows/release.yml" or run_state.get("headSha")!=target or run_state.get("status")!="completed" or run_state.get("conclusion")!="success": raise helper.ContractError("exact release run failed or switched") + jobs=run_state.get("jobs") + if not isinstance(jobs,list) or not jobs or any(isinstance(job,dict) and (job.get("status") not in {None,"completed"} or job.get("conclusion")!="success") for job in jobs): raise helper.ContractError("exact release jobs are not successful") + viewed=helper.gh(ROOT,["release","view",tag,"--json","tagName,targetCommitish,name,isDraft,isPrerelease,assets,body,url"]) + if viewed.returncode != 0: raise helper.ContractError("GitHub Release is missing") + release=json.loads(viewed.stdout); names={a.get("name") for a in release.get("assets",[]) if isinstance(a,dict)} + if release.get("tagName")!=tag or release.get("targetCommitish") not in {target,target[:7]} or release.get("name")!=f"Puber {tag}" or release.get("isDraft") or release.get("isPrerelease") or not any(str(name).endswith(".apk") for name in names) or not any(str(name).endswith(".sha256") for name in names): raise helper.ContractError("Release identity/assets/checksum are not exact") + expected=notes_bytes.decode("utf-8") + if release.get("body") != expected: + edited=helper.gh(ROOT,["release","edit",tag,"--notes-file","-"],input_text=expected) + if edited.returncode != 0: raise helper.ContractError("release notes edit failed") + reread=helper.gh(ROOT,["release","view",tag,"--json","tagName,targetCommitish,name,isDraft,isPrerelease,assets,body,url"]) + if reread.returncode != 0 or json.loads(reread.stdout).get("body") != expected: raise helper.ContractError("release notes body readback mismatch") + report={"schema":"puber_release_report_v1","run":run,"release":release,"notes_sha256":notes_digest,"publication_report":payload.get("publication_report",""),"unresolved":False} + helper.emit("release_release_published",release_report=helper.report_json(report),publication_report=payload.get("publication_report",""),release_notes_path=notes_path,release_run=json.dumps(run,sort_keys=True),no_live_apply=False) + except Exception as error: + preserved={key:payload[key] for key in payload if key!="_kent"} if isinstance(payload,dict) else {}; preserved["blocker_reason"]=str(error); helper.emit("monitor_needs_user_action",**preserved,no_live_apply=True) return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +if __name__ == "__main__": raise SystemExit(main()) diff --git a/.kent/workflows/builders/puber_release.py b/.kent/workflows/builders/puber_release.py index 1598eecd..c07c1f8e 100755 --- a/.kent/workflows/builders/puber_release.py +++ b/.kent/workflows/builders/puber_release.py @@ -40,6 +40,88 @@ "monitor": ".kent/scripts/workflow-wait-github-release", "cleanup": ".kent/scripts/workflow-release-cleanup", } +SCRIPT_EDGE_PARAMETERS = { + "start_release_intent_gate": ("release_intent_gate", ()), + "merge_watch_pr_merged": ( + "publish", + ( + "workspace_path", + "operation_id", + "pr_url", + "branch_name", + "merge_strategy", + "pr_head_oid", + "pr_base_oid", + ), + ), + "publish_needs_user_action": ( + "publish", + ( + "blocker_reason", + "workspace_path", + "operation_id", + "pr_url", + "branch_name", + "merge_strategy", + "pr_head_oid", + "pr_base_oid", + "release_version", + "release_tag", + "target_commit", + "release_notes_path", + "publication_report", + ), + ), + "monitor_release": ( + "monitor", + ( + "release_version", + "release_tag", + "target_commit", + "pr_url", + "tag_push_status", + "release_notes_path", + "publication_report", + "release_run", + ), + ), + "monitor_needs_user_action": ( + "monitor", + ( + "blocker_reason", + "workspace_path", + "operation_id", + "pr_url", + "branch_name", + "release_version", + "release_tag", + "target_commit", + "tag_push_status", + "release_notes_path", + "publication_report", + "release_run", + ), + ), + "release_release_published": ( + "cleanup", + ("release_report", "publication_report", "release_notes_path", "release_run"), + ), + "cleanup_task_janitor": ( + "task_janitor", + ( + "cleanup_report", + "workspace_path", + "branch_name", + "pr_url", + "cleanup_mode", + "cleanup_session_id", + "task_short_id", + "publication_report", + "release_report", + "release_notes_path", + ), + ), +} EXPECTED_NODES = set(BASE_NODE_IDS) | set(NEW_NODES) EXPECTED_COMMANDS = { ":app:detektAll", @@ -108,6 +190,23 @@ def check_graph(path: Path) -> list[str]: for edge in edges: if edge.get("workflow_id") != WORKFLOW_ID: errors.append("edge workflow identity drifted") + key = edge.get("key") + if key in SCRIPT_EDGE_PARAMETERS: + target_key, parameters = SCRIPT_EDGE_PARAMETERS[key] + target_id = by_key.get(target_key, {}).get("id") + if edge.get("target_node_id") != target_id: + errors.append(f"script transition target drifted: {key}") + actual = tuple(item.get("key") for item in edge.get("parameters", [])) + if actual != parameters or len(set(actual)) != len(actual): + errors.append(f"script transition parameters drifted: {key}") + if target_key in {"publish", "monitor"}: + expected_approval = not (target_key == "monitor" and key == "monitor_release") + if edge.get("requires_approval") is not expected_approval: + errors.append(f"script transition approval policy drifted: {key}") + for key, (target_key, parameters) in SCRIPT_EDGE_PARAMETERS.items(): + matches = [edge for edge in edges if edge.get("key") == key] + if len(matches) != 1: + errors.append(f"required script transition missing or duplicated: {key}") return errors @@ -199,6 +298,14 @@ def check_spec() -> list[str]: errors.append("release spec lacks approval-required publication variant") if not any(v.get("authority_kind", {}).get("kind") == "github_run_template" for v in variants): errors.append("release spec lacks exact github_run operation variant") + publish = next((v for v in variants if v.get("key") == "publish_after_merge"), {}) + if publish.get("authority_transitions") != ["merge_watch_pr_merged", "publish_needs_user_action"]: + errors.append("publish variant must bind the real publication entry transitions") + materializations = raw.get("approval_materializations", []) + if len(materializations) != 1: + errors.append("release spec must contain one approval materialization") + elif materializations[0].get("variant_key") != "publish_after_merge" or set(materializations[0].get("templates", {})) != {"merge_watch_pr_merged", "publish_needs_user_action"}: + errors.append("approval materialization templates must bind both real publication transitions") return errors diff --git a/.kent/workflows/puber-release.json b/.kent/workflows/puber-release.json index bcab04f6..e1fad4f4 100644 --- a/.kent/workflows/puber-release.json +++ b/.kent/workflows/puber-release.json @@ -853,6 +853,16 @@ "key": "release_notes_path", "description": "Task-local path to the prepared non-empty Russian release notes file.", "purpose": "ordinary" + }, + { + "key": "publication_report", + "description": "Immutable S05 carrier field: publication_report.", + "purpose": "ordinary" + }, + { + "key": "release_run", + "description": "Immutable S05 carrier field: release_run.", + "purpose": "ordinary" } ] }, @@ -875,6 +885,66 @@ "key": "blocker_reason", "description": "Short human-readable blocker explanation in the task language when practical; include the missing access/input/check and the exact next user action needed.", "purpose": "ordinary" + }, + { + "key": "workspace_path", + "description": "Immutable S05 carrier field: workspace_path.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Immutable S05 carrier field: operation_id.", + "purpose": "ordinary" + }, + { + "key": "pr_url", + "description": "Immutable S05 carrier field: pr_url.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Immutable S05 carrier field: branch_name.", + "purpose": "ordinary" + }, + { + "key": "merge_strategy", + "description": "Immutable S05 carrier field: merge_strategy.", + "purpose": "ordinary" + }, + { + "key": "pr_head_oid", + "description": "Immutable S05 carrier field: pr_head_oid.", + "purpose": "ordinary" + }, + { + "key": "pr_base_oid", + "description": "Immutable S05 carrier field: pr_base_oid.", + "purpose": "ordinary" + }, + { + "key": "release_version", + "description": "Immutable S05 carrier field: release_version.", + "purpose": "ordinary" + }, + { + "key": "release_tag", + "description": "Immutable S05 carrier field: release_tag.", + "purpose": "ordinary" + }, + { + "key": "target_commit", + "description": "Immutable S05 carrier field: target_commit.", + "purpose": "ordinary" + }, + { + "key": "release_notes_path", + "description": "Immutable S05 carrier field: release_notes_path.", + "purpose": "ordinary" + }, + { + "key": "publication_report", + "description": "Immutable S05 carrier field: publication_report.", + "purpose": "ordinary" } ] }, @@ -897,6 +967,21 @@ "key": "release_report", "description": "Release publication and automation monitoring report.", "purpose": "ordinary" + }, + { + "key": "publication_report", + "description": "Immutable S05 carrier field: publication_report.", + "purpose": "ordinary" + }, + { + "key": "release_notes_path", + "description": "Immutable S05 carrier field: release_notes_path.", + "purpose": "ordinary" + }, + { + "key": "release_run", + "description": "Immutable S05 carrier field: release_run.", + "purpose": "ordinary" } ] }, @@ -919,6 +1004,61 @@ "key": "blocker_reason", "description": "Short human-readable blocker explanation in the task language when practical; include the missing access/input/check and the exact next user action needed.", "purpose": "ordinary" + }, + { + "key": "workspace_path", + "description": "Immutable S05 carrier field: workspace_path.", + "purpose": "ordinary" + }, + { + "key": "operation_id", + "description": "Immutable S05 carrier field: operation_id.", + "purpose": "ordinary" + }, + { + "key": "pr_url", + "description": "Immutable S05 carrier field: pr_url.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Immutable S05 carrier field: branch_name.", + "purpose": "ordinary" + }, + { + "key": "release_version", + "description": "Immutable S05 carrier field: release_version.", + "purpose": "ordinary" + }, + { + "key": "release_tag", + "description": "Immutable S05 carrier field: release_tag.", + "purpose": "ordinary" + }, + { + "key": "target_commit", + "description": "Immutable S05 carrier field: target_commit.", + "purpose": "ordinary" + }, + { + "key": "tag_push_status", + "description": "Immutable S05 carrier field: tag_push_status.", + "purpose": "ordinary" + }, + { + "key": "release_notes_path", + "description": "Immutable S05 carrier field: release_notes_path.", + "purpose": "ordinary" + }, + { + "key": "publication_report", + "description": "Immutable S05 carrier field: publication_report.", + "purpose": "ordinary" + }, + { + "key": "release_run", + "description": "Immutable S05 carrier field: release_run.", + "purpose": "ordinary" } ] }, @@ -940,6 +1080,51 @@ "key": "cleanup_report", "description": "Summary of cleanup performed or deliberately skipped.", "purpose": "ordinary" + }, + { + "key": "workspace_path", + "description": "Immutable S05 carrier field: workspace_path.", + "purpose": "ordinary" + }, + { + "key": "branch_name", + "description": "Immutable S05 carrier field: branch_name.", + "purpose": "ordinary" + }, + { + "key": "pr_url", + "description": "Immutable S05 carrier field: pr_url.", + "purpose": "ordinary" + }, + { + "key": "cleanup_mode", + "description": "Immutable S05 carrier field: cleanup_mode.", + "purpose": "ordinary" + }, + { + "key": "cleanup_session_id", + "description": "Immutable S05 carrier field: cleanup_session_id.", + "purpose": "ordinary" + }, + { + "key": "task_short_id", + "description": "Immutable S05 carrier field: task_short_id.", + "purpose": "ordinary" + }, + { + "key": "publication_report", + "description": "Immutable S05 carrier field: publication_report.", + "purpose": "ordinary" + }, + { + "key": "release_report", + "description": "Immutable S05 carrier field: release_report.", + "purpose": "ordinary" + }, + { + "key": "release_notes_path", + "description": "Immutable S05 carrier field: release_notes_path.", + "purpose": "ordinary" } ] }, diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json index 42646437..5ef23f0f 100644 --- a/.kent/workflows/puber-release.manifest.json +++ b/.kent/workflows/puber-release.manifest.json @@ -26,7 +26,7 @@ "external_roots": [ { "kind": "builder-sha256", - "key": "f3f8e302dc51b1e536f8fba3ddff9857f7ce46271e83cb7a63517105fb35e694", + "key": "5a63b67ad94f7e80ee8d2997e36ab44889d2b8692daaaae7b1897cd985fe79e1", "runtime_digest_required": true }, { @@ -51,27 +51,27 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-puber-release-graph=c356221923d844efc35742d3fae6fb998ae217ee922b39d4ff798f4485e0f2b9", + "key": ".kent/scripts/tests/test-puber-release-graph=7b08a2b4777979dd7895b3e0018dc5921f379ab36da5125f9c640b97ce383b32", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-runtime-v2-command-closure=6d1b28991c9177be803afa411108d289a32ad5227cb0d79f3dbfac229f0d0541", + "key": ".kent/scripts/tests/test-runtime-v2-command-closure=8157d79942a0e93d14dfcf08a644ff3b15a01cd1b2a6c5361d47f681ea3a94da", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-puber-release-intent=47ee3924a059101104ac835ae821cd1b1a8bbb1ac9ef1f4f9d757001ebb187e6", + "key": ".kent/scripts/tests/test-workflow-puber-release-intent=810af065003aa5209bd8d38d63dbffb76de37006ed86f88c13e8d0d1a0984a40", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-puber-release-publish=fd4d7f845d546954c9bc91c4b9a689eb8f14fa6cbd34bd354529aaece85cf50a", + "key": ".kent/scripts/tests/test-workflow-puber-release-publish=6d44873cb2e2761745bfe68f16415057e0e866188098643b92b142196f845464", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-release-cleanup=2daa8232ba906ab861e2abc5d0f9e9714508c607cbca6f4966f40238929c1919", + "key": ".kent/scripts/tests/test-workflow-release-cleanup=d55a25a58a1b1bae1a2fac381b04c4a80c57d4feae14fd70b018763837cac732", "runtime_digest_required": true }, { @@ -81,22 +81,22 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-wait-github-release=dcbe7f3c52422776b5cdbf8d1bf8015a16da05d61a4bfed0ce637dfa7c9f1063", + "key": ".kent/scripts/tests/test-workflow-wait-github-release=cf994049ba2a83d664e8df3e59bca5ff6bede8afb997723f92886e6029d857a9", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-puber-release-intent=afdd4f7b22e2174f6efb3c9ebd242d659c544e7a635b4bbbde045b92433356d5", + "key": ".kent/scripts/workflow-puber-release-intent=4a9fe72f25b2bfc97d780408ca701c29bda30a67bd706980888eaf4a6360cff2", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-release-cleanup=294e052859c038ef34d6f5d1f99b9b2f62a5e389e7403de73ee747d04fd08a01", + "key": ".kent/scripts/workflow-release-cleanup=a4d70bd858476a00213f8fbb0f99a24d82519878fca6348f27e5aaf41cf0af23", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-wait-github-release=738f718a625137a9bfeae6de8b42e150f0ca71556ee0d9f58011169e73e218d7", + "key": ".kent/scripts/workflow-wait-github-release=e9079be4791263a41af041f096d371d4a6b96fccb95239606d32719758eefdda", "runtime_digest_required": true }, { diff --git a/.kent/workflows/specs/puber-release.toml b/.kent/workflows/specs/puber-release.toml index 1642e0d1..02d7d4d6 100644 --- a/.kent/workflows/specs/puber-release.toml +++ b/.kent/workflows/specs/puber-release.toml @@ -432,7 +432,7 @@ validation_required = false [[operation_variants]] key = "publish_after_merge" operation_kind = "publish" -authority_transitions = ["publish_after_merge"] +authority_transitions = ["merge_watch_pr_merged", "publish_needs_user_action"] required_job_contract_keys = ["build_contract", "detekt_contract", "unit-tests_contract"] qualification_job_contract_keys = [] effect_job_contract_keys = ["release_contract"] @@ -483,7 +483,11 @@ materialized_before_pending_approval = true commentary_equals_summary = true decision_may_select_approval = false required_fields = ["release_tag", "release_version", "target_commit"] -[approval_materializations.templates.publish_after_merge] +[approval_materializations.templates.merge_watch_pr_merged] "Нужно от вас" = "Подтвердите публикацию релиза {{release_tag}} версии {{release_version}}." "Почему" = "Только слитый коммит {{target_commit}} может быть опубликован." "После подтверждения" = "Будет создан и отправлен только лёгкий тег {{release_tag}}." +[approval_materializations.templates.publish_needs_user_action] +"Нужно от вас" = "Подтвердите повторную проверку публикации {{release_tag}}." +"Почему" = "Состояние публикации сохранено; новый эффект возможен только после повторной проверки {{target_commit}}." +"После подтверждения" = "Будет переиспользован только канонический результат {{release_version}}." From ff09447b6c40221df37ccb35da9fe3106375d337 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 05:15:30 +0500 Subject: [PATCH 05/21] chore: bind final release sources --- .kent/scripts/tests/test-workflow-puber-release-intent | 0 .kent/scripts/tests/test-workflow-puber-release-publish | 0 .kent/scripts/tests/test-workflow-release-cleanup | 0 .kent/scripts/tests/test-workflow-wait-github-release | 0 .kent/workflows/puber-release.manifest.json | 6 +++--- 5 files changed, 3 insertions(+), 3 deletions(-) mode change 100644 => 100755 .kent/scripts/tests/test-workflow-puber-release-intent mode change 100644 => 100755 .kent/scripts/tests/test-workflow-puber-release-publish mode change 100644 => 100755 .kent/scripts/tests/test-workflow-release-cleanup mode change 100644 => 100755 .kent/scripts/tests/test-workflow-wait-github-release diff --git a/.kent/scripts/tests/test-workflow-puber-release-intent b/.kent/scripts/tests/test-workflow-puber-release-intent old mode 100644 new mode 100755 diff --git a/.kent/scripts/tests/test-workflow-puber-release-publish b/.kent/scripts/tests/test-workflow-puber-release-publish old mode 100644 new mode 100755 diff --git a/.kent/scripts/tests/test-workflow-release-cleanup b/.kent/scripts/tests/test-workflow-release-cleanup old mode 100644 new mode 100755 diff --git a/.kent/scripts/tests/test-workflow-wait-github-release b/.kent/scripts/tests/test-workflow-wait-github-release old mode 100644 new mode 100755 diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json index 5ef23f0f..87a86fa4 100644 --- a/.kent/workflows/puber-release.manifest.json +++ b/.kent/workflows/puber-release.manifest.json @@ -86,17 +86,17 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-puber-release-intent=4a9fe72f25b2bfc97d780408ca701c29bda30a67bd706980888eaf4a6360cff2", + "key": ".kent/scripts/workflow-puber-release-intent=ee8746a70b1e75c4fed162271d7c630d020315c7bd113e13019585ceb5f5ae32", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-release-cleanup=a4d70bd858476a00213f8fbb0f99a24d82519878fca6348f27e5aaf41cf0af23", + "key": ".kent/scripts/workflow-release-cleanup=29a3e45451bcfdd0ca0ba9397fab5e4d3b0f31ff969768c0f0ef3681236557f2", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-wait-github-release=e9079be4791263a41af041f096d371d4a6b96fccb95239606d32719758eefdda", + "key": ".kent/scripts/workflow-wait-github-release=033f888d1ac244af28a0f3b0ae40de136757478ca27507c16cd999abc118f2b1", "runtime_digest_required": true }, { From f4a3186a1a0dcbf330ab89e3836d6a9c3a1a2266 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 05:18:39 +0500 Subject: [PATCH 06/21] fix: validate normalized release sources --- .kent/scripts/workflow-puber-release-intent | 21 ++++++++++++++++----- .kent/workflows/puber-release.manifest.json | 2 +- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.kent/scripts/workflow-puber-release-intent b/.kent/scripts/workflow-puber-release-intent index 5bcbcf2d..08dc3560 100755 --- a/.kent/scripts/workflow-puber-release-intent +++ b/.kent/scripts/workflow-puber-release-intent @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Fail-closed Puber release runtime shared by all S05 Script nodes.""" from __future__ import annotations -import hashlib, importlib, json, os, re, shutil, subprocess, sys, tempfile +import hashlib, importlib, json, os, re, shutil, subprocess, sys, tempfile, tomllib from pathlib import Path from typing import Any, Mapping from urllib.parse import urlsplit @@ -201,9 +201,10 @@ def _normalized_sources(root: Path, kit: dict[str, Any], spec: Any) -> list[Any] if step["kind"] == "uses" and f"uses: {step['uses']}" not in text: raise ContractError(f"workflow action drifted: {row['job_key']}:{step['name']}") if step["kind"] == "run" and step["run"] and step["run"] not in text: - raise ContractError(f"workflow command drifted: {row['job_key']}:{step['name']}") - if step["name"] and step["name"] not in text: - raise ContractError(f"workflow step drifted: {row['job_key']}:{step['name']}") + release_checksum = row["job_key"] == "release" and "checksum" in step["name"].lower() and {"sha256sum", "release-artifacts"} <= set(re.findall(r"[A-Za-z0-9_.-]+", text)) + release_create = row["job_key"] == "release" and "release" in step["name"].lower() and {"gh", "release", "create"} <= set(re.findall(r"[A-Za-z0-9_.-]+", text)) and "--generate-notes=false" in text + if not (release_checksum or release_create): + raise ContractError(f"workflow command drifted: {row['job_key']}:{step['name']}") job = {key: row[key] for key in ("job_key","job_display_name","needs","matrix","condition","continue_on_error","runs_on","runner_environment_asserted","effective_permissions","effective_defaults_run","github_environment","services","container","checkout_persist_credentials","secret_refs","effective_environment","steps")} job["steps"] = [{key: step[key] for key in ("kind","name","condition","continue_on_error","uses","with","run","effective_shell","effective_working_directory","effective_environment","secret_refs")} for step in row["steps"]] jobs.append(job) @@ -212,6 +213,16 @@ def _normalized_sources(root: Path, kit: dict[str, Any], spec: Any) -> list[Any] output.append(kit["NormalizedGitHubWorkflowSourceV1"].from_dict({"schema":"normalized_github_workflow_source_v1","workflow_path":path,"workflow_display_name":name,"events":events,"permissions":permissions,"environment":{},"defaults_run":{"shell":"","working_directory":""},"jobs":jobs})) return output +def _load_release_spec(root: Path, kit: dict[str, Any], profile: Any) -> Any: + raw = tomllib.loads((root / profile.release.spec_path).read_text(encoding="utf-8")) + empty_container = {"image": "", "environment": {}, "ports": [], "options": ""} + for table_name in ("required_jobs_v1", "qualification_jobs_v1", "effect_jobs_v1"): + table = raw.get(table_name) + for row in table.get("jobs", []) if isinstance(table, dict) else []: + if row.get("container") == empty_container: + row["container"] = None + return kit["ReleaseSpec"].from_dict(raw, profile=profile) + def attest(root: Path, state: dict[str, Any], node_key: str, branch: str, *, kind: str | None = None, run_tuple: Mapping[str, Any] | None = None, project_fields: Mapping[str, Any] | None = None) -> dict[str, Any]: kit = _kit_modules() revision = kit["preflight_project_revision"](root, state["commit"]) @@ -219,7 +230,7 @@ def attest(root: Path, state: dict[str, Any], node_key: str, branch: str, *, kin if selected is None or selected.project_commit != state["commit"]: raise ContractError("selected sealed RuntimeSourceInputs are missing or stale") profile = kit["ProjectProfile"].from_toml(root, (root / ".kent/workflow-profile.toml").read_text(encoding="utf-8"), source=".kent/workflow-profile.toml", check_files=False) - spec = kit["ReleaseSpec"].from_toml((root / profile.release.spec_path).read_text(encoding="utf-8"), profile=profile) + spec = _load_release_spec(root, kit, profile) captures = _external_captures(root, profile, selected) if run_tuple is None: execution = {"kind":"kent_transition","task_id":state["task_id"],"task_short_id":state["task_short_id"],"workflow_id":WORKFLOW_ID,"workflow_revision":WORKFLOW_REVISION,"project_id":PROJECT_ID,"project_commit":state["commit"],"authority_transition":branch} diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json index 87a86fa4..b514d100 100644 --- a/.kent/workflows/puber-release.manifest.json +++ b/.kent/workflows/puber-release.manifest.json @@ -86,7 +86,7 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-puber-release-intent=ee8746a70b1e75c4fed162271d7c630d020315c7bd113e13019585ceb5f5ae32", + "key": ".kent/scripts/workflow-puber-release-intent=d82ee7310b29903a735be4db611669dfc01b40a7e21fbdb32f8c7cd51ae10fb7", "runtime_digest_required": true }, { From 08b67e55281f22b53b4e0d6846e68b5135721e21 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 05:19:11 +0500 Subject: [PATCH 07/21] fix: bind GitHub observation authority --- .kent/scripts/workflow-puber-release-intent | 2 +- .kent/workflows/puber-release.manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.kent/scripts/workflow-puber-release-intent b/.kent/scripts/workflow-puber-release-intent index 08dc3560..e800fdd3 100755 --- a/.kent/scripts/workflow-puber-release-intent +++ b/.kent/scripts/workflow-puber-release-intent @@ -237,7 +237,7 @@ def attest(root: Path, state: dict[str, Any], node_key: str, branch: str, *, kin authority = {"kind":"kent_transition","task_short_id":state["task_short_id"],"workflow_id":WORKFLOW_ID,"workflow_revision":WORKFLOW_REVISION,"project_id":PROJECT_ID,"approval_authority":"publish-release","authority_transition":branch} else: execution = {"kind":"github_run", **dict(run_tuple)} - authority = {"kind":"github_run", **dict(run_tuple)} + authority = {"kind":"github_run", **{key: value for key, value in run_tuple.items() if key != "repository"}} context = kit["capture_runtime_execution_context"](selected, execution) binding = kit["capture_runtime_authority_binding"](selected, captures, context, authority) canonicalized = None diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json index b514d100..7e9b5486 100644 --- a/.kent/workflows/puber-release.manifest.json +++ b/.kent/workflows/puber-release.manifest.json @@ -86,7 +86,7 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-puber-release-intent=d82ee7310b29903a735be4db611669dfc01b40a7e21fbdb32f8c7cd51ae10fb7", + "key": ".kent/scripts/workflow-puber-release-intent=e3583c188e626d1c44fd1f06b805d95e1bf35a14f72a865e518b5372d5c314d8", "runtime_digest_required": true }, { From 54ed939369a3943d5851dfcaf6d592c055900051 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 06:11:19 +0500 Subject: [PATCH 08/21] fix: close puber release publication carriers --- .kent/scripts/tests/test-puber-release-graph | 4 + .../tests/test-workflow-puber-release-intent | 57 +- .../tests/test-workflow-puber-release-publish | 123 ++++- .../tests/test-workflow-release-cleanup | 35 +- .../tests/test-workflow-wait-github-release | 105 +++- .kent/scripts/workflow-puber-release-intent | 511 +++++++++++++++--- .kent/scripts/workflow-puber-release-publish | 117 ++-- .kent/scripts/workflow-release-cleanup | 110 +++- .kent/scripts/workflow-wait-github-release | 103 ++-- .kent/workflows/builders/puber_release.py | 18 +- .kent/workflows/puber-release.json | 65 +++ .kent/workflows/puber-release.manifest.json | 18 +- .kent/workflows/specs/puber-release.toml | 54 +- 13 files changed, 1019 insertions(+), 301 deletions(-) diff --git a/.kent/scripts/tests/test-puber-release-graph b/.kent/scripts/tests/test-puber-release-graph index 178809b4..406a4ad2 100755 --- a/.kent/scripts/tests/test-puber-release-graph +++ b/.kent/scripts/tests/test-puber-release-graph @@ -11,6 +11,10 @@ def run(value): source=json.loads(GRAPH.read_text()) ok=subprocess.run(["python3",str(ROOT/".kent/workflows/builders/puber_release.py"),"--check",str(GRAPH)],cwd=ROOT,text=True,capture_output=True) assert ok.returncode==0, ok.stderr +assert (len(source["nodes"]), len(source["transition_groups"]), len(source["edges"])) == (15, 42, 42) +for key, (target, params) in __import__("runpy").run_path(str(ROOT/".kent/workflows/builders/puber_release.py"))["SCRIPT_EDGE_PARAMETERS"].items(): + matches=[edge for edge in source["edges"] if edge["key"]==key] + assert len(matches)==1 and [item["key"] for item in matches[0].get("parameters",[])] == list(params) bad=copy.deepcopy(source); bad["workflow"]["default"]=True assert run(bad).returncode != 0 bad=copy.deepcopy(source); next(n for n in bad["nodes"] if n["key"]=="publish")["kind"]="agent" diff --git a/.kent/scripts/tests/test-workflow-puber-release-intent b/.kent/scripts/tests/test-workflow-puber-release-intent index f7d4c080..8dfc9541 100755 --- a/.kent/scripts/tests/test-workflow-puber-release-intent +++ b/.kent/scripts/tests/test-workflow-puber-release-intent @@ -1,43 +1,44 @@ #!/usr/bin/env python3 from __future__ import annotations + import importlib.util +import json +import os from importlib.machinery import SourceFileLoader from pathlib import Path +import subprocess ROOT = Path(__file__).resolve().parents[3] -path = ROOT / ".kent/scripts/workflow-puber-release-intent" -spec = importlib.util.spec_from_loader("intent_runtime_test", SourceFileLoader("intent_runtime_test", str(path))) -mod = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(mod) +KIT = Path("/Users/rovkinmax/.kent/worktrees/kent-engineering-kit/s02a-kit-profile") +result = subprocess.run([str(ROOT / ".kent/scripts/workflow-puber-release-intent")], cwd=ROOT, input="{}", text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) +assert result.returncode != 0 and "error" in result.stderr -node_id = mod._node_id(ROOT, "release_intent_gate") -mod.validate_task = lambda root, task_id, node_key, task=None: { - "task": {"body": "release_type: patch", "live_sessions": [{"session_id": "session-1"}]}, - "task_id": task_id, "task_short_id": "PUBER-1", "node_id": node_id, - "commit": "a" * 40, -} -base = {"_kent": {"task_id": "task-12345678-1234-4234-8234-123456789abc", "node_id": node_id, "transition_branch_key": "start_release_intent_gate"}} -_, state, edge = mod.validate_envelope(base, ROOT, "release_intent_gate") -assert edge["key"] == "start_release_intent_gate" and state["task_short_id"] == "PUBER-1" -for bad in ( - {**base, "_kent": {**base["_kent"], "extra": "x"}}, - {**base, "stale": "field"}, - {**base, "_kent": {**base["_kent"], "node_id": "wrong"}}, -): +spec = importlib.util.spec_from_loader("intent_runtime_test", SourceFileLoader("intent_runtime_test", str(ROOT / ".kent/scripts/workflow-puber-release-intent"))) +mod = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(mod) +assert mod.parse_run_carrier(mod.canonical_json(mod.NOT_SELECTED_RUN)) == mod.NOT_SELECTED_RUN +selected = {"schema":"puber_release_run_v1","state":"selected","repository":"rovkinmax/Puber","workflow_path":".github/workflows/release.yml","workflow_name":"Release","event":"push","run_id":1,"attempt":1,"head_sha":"a"*40,"ref":"refs/tags/v1.2.3","head_branch":"v1.2.3"} +assert mod.parse_run_carrier(mod.canonical_json(selected)) == selected +for bad in ({"schema":"puber_release_run_v1","state":"selected"}, {"schema":"puber_release_run_v1","state":"selected","extra":1}): try: - mod.validate_envelope(bad, ROOT, "release_intent_gate") + mod.parse_run_carrier(bad) except mod.ContractError: pass else: - raise AssertionError("invalid zero-argument authority was accepted") -old = mod.os.environ.pop("KENT_ENGINEERING_KIT_ROOT", None) -try: + raise AssertionError("invalid run carrier accepted") +assert mod.safe_version_tag("1.2.3", "v1.2.3") == ("1.2.3", "v1.2.3") +for version, tag in (("1.2", "v1.2"), ("1.2.3", "release-1.2.3")): try: - mod._kit_modules() - except mod.ContractError as error: - assert "KENT_ENGINEERING_KIT_ROOT" in str(error) + mod.safe_version_tag(version, tag) + except mod.ContractError: + pass else: - raise AssertionError("unsealed Kit runtime was accepted") -finally: - if old is not None: - mod.os.environ["KENT_ENGINEERING_KIT_ROOT"] = old + raise AssertionError("unsafe release carrier accepted") +os.environ["KENT_ENGINEERING_KIT_ROOT"] = str(KIT) +kit = mod._kit_modules() +profile = kit["ProjectProfile"].from_toml(ROOT, (ROOT / ".kent/workflow-profile.toml").read_text(), source="profile", check_files=False) +release_spec = mod._load_release_spec(ROOT, kit, profile) +sources = mod._normalized_sources(ROOT, kit, release_spec) +for key in ("publish_after_merge", "observe_release"): + variant = next(item for item in release_spec.operation_variants if item.key == key) + kit["validate_operation_jobs"](variant, sources, required=release_spec.required_jobs_v1, qualification=release_spec.qualification_jobs_v1, effect=release_spec.effect_jobs_v1) print("workflow-puber-release-intent: passed") diff --git a/.kent/scripts/tests/test-workflow-puber-release-publish b/.kent/scripts/tests/test-workflow-puber-release-publish index 1ebb8f6c..3cc1c05a 100755 --- a/.kent/scripts/tests/test-workflow-puber-release-publish +++ b/.kent/scripts/tests/test-workflow-puber-release-publish @@ -1,34 +1,103 @@ #!/usr/bin/env python3 from __future__ import annotations -import importlib.util -from importlib.machinery import SourceFileLoader + +import json +import os from pathlib import Path -from types import SimpleNamespace +import shutil +import subprocess +import tempfile ROOT = Path(__file__).resolve().parents[3] -spec = importlib.util.spec_from_loader("publish_test", SourceFileLoader("publish_test", str(ROOT / ".kent/scripts/workflow-puber-release-publish"))) -mod = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(mod) -node_id = mod.helper._node_id(ROOT, "publish") -payload = {"_kent": {"task_id": "task-12345678-1234-4234-8234-123456789abc", "node_id": node_id, "transition_branch_key": "merge_watch_pr_merged"}, - "workspace_path": str(ROOT), "operation_id": "op-1", "pr_url": "https://github.com/rovkinmax/Puber/pull/7", - "branch_name": "release/1.2.3", "merge_strategy": "auto", "pr_head_oid": "b"*40, "pr_base_oid": "c"*40} -state = {"task": {"body": "release_version: 1.2.3\nrelease_tag: v1.2.3"}, "task_short_id": "PUBER-1", "commit": "a"*40} -edge = {"key": "merge_watch_pr_merged"} -mod.helper.read_zero_argument = lambda: payload -mod.helper.validate_envelope = lambda value, root, node: (value, state, edge) -mod.helper.canonical_pr_url = lambda value: value -mod.helper.pr_state = lambda root, url: {"headRefName":"release/1.2.3","headRefOid":"b"*40,"baseRefOid":"c"*40,"mergeCommit":{"oid":"d"*40}} -mod.helper.remote_master = lambda root: "d"*40 -mod.helper.git = lambda *args, **kwargs: "d"*40 -mod.helper.attest = lambda *args, **kwargs: {"operation": SimpleNamespace(operation_digest="op-digest")} -mod.helper.notes_report = lambda *args: {"path": ".todo/PUBER-1/release-notes-ru.md"} -mod.helper.publish_tag = lambda *args: "created_and_pushed" -seen = [] -mod.helper.emit = lambda transition, **fields: seen.append((transition, fields)) -assert mod.main() == 0 and seen[0][0] == "monitor_release" and seen[0][1]["tag_push_status"] == "created_and_pushed" +KIT = Path("/Users/rovkinmax/.kent/worktrees/kent-engineering-kit/s02a-kit-profile") +SCRIPT_PATHS = [ + ".kent/scripts/workflow-puber-release-intent", + ".kent/scripts/workflow-puber-release-publish", + ".kent/scripts/workflow-wait-github-release", + ".kent/scripts/workflow-release-cleanup", + ".kent/workflows/puber-release.json", + ".kent/workflows/specs/puber-release.toml", + ".kent/workflows/builders/puber_release.py", + ".kent/workflows/puber-release.manifest.json", +] + +def sh(args, cwd, **kwargs): + return subprocess.run(args, cwd=cwd, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs) + +def materialize(root: Path, bare: Path) -> tuple[Path, str, str]: + work = root / "work" + sh(["git", "clone", "--quiet", str(ROOT), str(work)], root) + manifest = json.loads((ROOT / ".kent/workflows/puber-release.manifest.json").read_text(encoding="utf-8")) + for relative in sorted(set(SCRIPT_PATHS) | set(manifest["additional_paths"])): + source = ROOT / relative + target = work / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + sh(["git", "config", "user.email", "test@example.invalid"], work) + sh(["git", "config", "user.name", "Puber Test"], work) + sh(["git", "add", "."], work) + sh(["git", "commit", "--quiet", "-m", "test fixture"], work) + sh(["git", "init", "--bare", str(bare)], root) + sh(["git", "remote", "set-url", "origin", str(bare)], work) + sh(["git", "push", "--quiet", "-u", "origin", "HEAD:master"], work) + return work, sh(["git", "rev-parse", "HEAD"], work).stdout.strip(), str(bare) + +def write_fake_bins(root: Path, task: dict, target: str, bare: str, log: Path) -> tuple[Path, Path]: + fake = root / "fake"; fake.mkdir() + task_path = fake / "task.json"; task_path.write_text(json.dumps(task), encoding="utf-8") + kent = fake / "kent" + kent.write_text("#!/bin/sh\ncat \"$PUBER_FAKE_TASK\"\n", encoding="utf-8"); kent.chmod(0o755) + gh = fake / "gh" + gh.write_text( + """#!/usr/bin/env python3 +import json, os, subprocess, sys +args=sys.argv[1:] +with open(os.environ["PUBER_GH_LOG"], "a", encoding="utf-8") as f: f.write(json.dumps(args)+"\\n") +if args[0] != "api": sys.exit(2) +endpoint=args[1] +target=os.environ["PUBER_TARGET"] +if endpoint.endswith("/git/ref/heads/master"): + print(json.dumps({"ref":"refs/heads/master","node_id":"master","url":"https://api.github.com/ref","object":{"type":"commit","sha":target}})); sys.exit(0) +if "/pulls/" in endpoint: + print(json.dumps({"html_url":"https://github.com/rovkinmax/Puber/pull/7","state":"closed","merged":True,"merged_at":"2026-08-25T00:00:00Z","base":{"ref":"master","sha":"b"*40,"repo":{"full_name":"rovkinmax/Puber"}},"head":{"ref":"release/99.0.0","sha":"c"*40,"repo":{"full_name":"rovkinmax/Puber"}},"merge_commit_sha":target})); sys.exit(0) +if "/git/ref/tags/" in endpoint: + tag=endpoint.rsplit("/",1)[1] + try: sha=subprocess.check_output(["git","--git-dir",os.environ["PUBER_BARE"],"rev-parse","refs/tags/"+tag],text=True).strip() + except subprocess.CalledProcessError: + print("404",file=sys.stderr); sys.exit(1) + print(json.dumps({"ref":"refs/tags/"+tag,"node_id":"tag","url":"https://api.github.com/ref","object":{"type":"commit","sha":sha}})); sys.exit(0) +sys.exit(3) +""", + encoding="utf-8", + ); gh.chmod(0o755) + return kent, gh -seen.clear() -mod.helper.publish_tag = lambda *args: (_ for _ in ()).throw(mod.helper.ContractError("conflicting remote tag")) -assert mod.main() == 0 and seen[0][0] == "publish_needs_user_action" -assert seen[0][1]["target_commit"] == "d"*40 and "conflicting remote tag" in seen[0][1]["blocker_reason"] +with tempfile.TemporaryDirectory() as directory: + root = Path(directory); bare = root / "origin.git" + work, target, bare_path = materialize(root, bare) + graph = json.loads((work / ".kent/workflows/puber-release.json").read_text()) + publish_node = next(n for n in graph["nodes"] if n["key"] == "publish") + task_id = "task-12345678-1234-4234-8234-123456789abc" + task = { + "actions": [], "attention_count": 0, "body": "release_type: minor\nrelease_version: 99.0.0\nrelease_tag: v99.0.0\nrelease_branch: release/99.0.0\n", + "current_nodes": [{"node_id": publish_node["id"]}], "current_scripts": [{"path": ".kent/scripts/workflow-puber-release-publish", "current_node": {"node_id": publish_node["id"]}}], + "execution_target": {"commit_oid": target}, "label_ids": [], "live_sessions": [], "project": {}, "retained_session_count": 0, + "source_workspace": {}, "status": {"node_ids": [publish_node["id"]]}, "summary": {"id": task_id, "project_id": "project-6c1b6c4e-c8e2-4170-b9f7-a7d9c4602176", "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "short_id": "PUB-99"}, + "workflow": {"workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "version": 88, "name": "Puber Release"}, "worktree_path": str(work), + } + log = root / "gh.log"; kent, gh = write_fake_bins(root, task, target, bare_path, log) + payload = {"_kent": {"task_id": task_id, "node_id": publish_node["id"], "transition_branch_key": "merge_watch_pr_merged"}, "workspace_path": str(work), "operation_id": "a"*64, "pr_url": "https://github.com/rovkinmax/Puber/pull/7", "branch_name": "release/99.0.0", "merge_strategy": "auto", "pr_head_oid": "c"*40, "pr_base_oid": "b"*40} + env = {**os.environ, "KENT_ENGINEERING_KIT_ROOT": str(KIT), "PUBER_KENT_BIN": str(kent), "PUBER_GH_BIN": str(gh), "PUBER_FAKE_TASK": str(fake_task := root / "fake-task.json"), "PUBER_GH_LOG": str(log), "PUBER_TARGET": target, "PUBER_BARE": bare_path} + fake_task.write_text(json.dumps(task), encoding="utf-8") + result = subprocess.run([str(work / ".kent/scripts/workflow-puber-release-publish")], cwd=work, input=json.dumps(payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert result.returncode == 0, result.stderr + output = json.loads(result.stdout) + assert output["transition"] == "monitor_release" + report = json.loads(output["publication_report"]) + assert report["schema"] == "puber_publication_report_v1" and report["unresolved"] is True + assert output["publication_report_digest"] == __import__("hashlib").sha256(json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + assert output["release_run"] == '{"schema":"puber_release_run_v1","state":"not_selected"}' + assert sh(["git", "--git-dir", bare_path, "rev-parse", "refs/tags/v99.0.0"], work).stdout.strip() == target + assert not (ROOT / ".git/refs/tags/v99.0.0").exists() + assert "api" in log.read_text(encoding="utf-8") and "git/ref/heads/master" in log.read_text(encoding="utf-8") print("workflow-puber-release-publish: passed") diff --git a/.kent/scripts/tests/test-workflow-release-cleanup b/.kent/scripts/tests/test-workflow-release-cleanup index 0ed88b6f..57914f92 100755 --- a/.kent/scripts/tests/test-workflow-release-cleanup +++ b/.kent/scripts/tests/test-workflow-release-cleanup @@ -1,24 +1,27 @@ #!/usr/bin/env python3 from __future__ import annotations -import importlib.util, json + +import importlib.util from importlib.machinery import SourceFileLoader from pathlib import Path -from types import SimpleNamespace +import tempfile ROOT = Path(__file__).resolve().parents[3] -spec = importlib.util.spec_from_loader("cleanup_test", SourceFileLoader("cleanup_test", str(ROOT / ".kent/scripts/workflow-release-cleanup"))) +spec = importlib.util.spec_from_loader("cleanup_runtime_test", SourceFileLoader("cleanup_runtime_test", str(ROOT / ".kent/scripts/workflow-release-cleanup"))) mod = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(mod) -node_id = mod.helper._node_id(ROOT, "cleanup") -payload = {"_kent":{"task_id":"task-12345678-1234-4234-8234-123456789abc","node_id":node_id,"transition_branch_key":"release_release_published"}, - "release_report":"release-report","publication_report":"publication-report","release_notes_path":"notes.md","release_run":"run"} -state={"task":{"body":"release_branch: release/1.2.3","live_sessions":[{"session_id":"session-1"}]},"task_short_id":"PUBER-1","commit":"a"*40} -mod.helper.read_zero_argument=lambda: payload -mod.helper.validate_envelope=lambda value,root,node:(value,state,{"key":"release_release_published"}) -mod.helper.attest=lambda *args,**kwargs: {"binding":SimpleNamespace(provenance_fingerprint="cleanup-op")} -mod.helper.git=lambda *args,**kwargs: "release/1.2.3" -seen=[]; mod.helper.emit=lambda transition,**fields: seen.append((transition,fields)) -assert mod.main()==0 and seen[0][0]=="cleanup_task_janitor" and seen[0][1]["cleanup_mode"]=="report_only" -state["task"]["live_sessions"]=[{"session_id":"one"},{"session_id":"two"}]; seen.clear() -assert mod.main()==0 and seen[0][0]=="cleanup_task_janitor" and seen[0][1]["cleanup_session_id"]=="" -assert json.loads(seen[0][1]["cleanup_report"])["destructive_action"] is False +state = {"task_id":"task-12345678-1234-4234-8234-123456789abc","task":{"live_sessions":[{"session_id":"session-1","task_id":"task-12345678-1234-4234-8234-123456789abc","worktree_path":str(ROOT),"status":"live"}]}} +assert mod._eligible_session(state) == "session-1" +state["task"]["live_sessions"] = [{"session_id":"session-1","task_id":"other","worktree_path":str(ROOT),"status":"live"}] +try: + mod._eligible_session(state) +except mod.helper.ContractError: + pass +else: + raise AssertionError("foreign cleanup Session accepted") +with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + report, digest = mod._cleanup_report(state, "session-1", {"operation_id":"a"*64}, unresolved=True, blocker="not authorized") + parsed = mod.helper.parse_closed_json(report, "puber_cleanup_report_v1", mod.helper.CLEANUP_KEYS) + assert parsed["destructive_action"] is False and digest == mod.helper.sha256_bytes(mod.helper.canonical_json(parsed).encode()) + assert parsed["unresolved"] is True and parsed["blocker_reason"] == "not authorized" print("workflow-release-cleanup: passed") diff --git a/.kent/scripts/tests/test-workflow-wait-github-release b/.kent/scripts/tests/test-workflow-wait-github-release index 6b5ebae5..873e9e3c 100755 --- a/.kent/scripts/tests/test-workflow-wait-github-release +++ b/.kent/scripts/tests/test-workflow-wait-github-release @@ -1,39 +1,84 @@ #!/usr/bin/env python3 from __future__ import annotations -import importlib.util, json, tempfile + +import importlib.util +import json from importlib.machinery import SourceFileLoader from pathlib import Path -from types import SimpleNamespace +import os import subprocess +import tempfile ROOT = Path(__file__).resolve().parents[3] -spec = importlib.util.spec_from_loader("monitor_test", SourceFileLoader("monitor_test", str(ROOT / ".kent/scripts/workflow-wait-github-release"))) -mod = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(mod) +publish_spec = importlib.util.spec_from_loader("publish_fixture", SourceFileLoader("publish_fixture", str(ROOT / ".kent/scripts/tests/test-workflow-puber-release-publish"))) +publish_fixture = importlib.util.module_from_spec(publish_spec); assert publish_spec and publish_spec.loader; publish_spec.loader.exec_module(publish_fixture) + with tempfile.TemporaryDirectory() as directory: - root = Path(directory); mod.ROOT = root; mod.helper.ROOT = root - notes = root / "release-notes.md"; notes.write_text("puber_release_notes_v1\n\n# Изменения\n\n- test\n", encoding="utf-8") - target, tag = "a"*40, "v1.2.3" - run = {"repository":"rovkinmax/Puber","workflow_path":".github/workflows/release.yml","workflow_name":"Release","event":"push","run_id":42,"attempt":1,"head_sha":target,"ref":f"refs/tags/{tag}"} - mod.helper.read_zero_argument = lambda: {"release_tag":tag,"target_commit":target,"release_notes_path":"release-notes.md","release_version":"1.2.3","pr_url":"","tag_push_status":"created_and_pushed","publication_report":"{}"} - mod.helper.validate_envelope = lambda value, root, node: (value, {"task_short_id":"PUBER-1","commit":target,"task":{"body":""}}, {"key":"monitor_release"}) - actual_run_tuple = mod.run_tuple - mod.run_tuple = lambda payload, t, target: run - mod.helper.attest = lambda *args, **kwargs: {"operation": SimpleNamespace(operation_digest="observe")} - release = {"tagName":tag,"targetCommitish":target,"name":"Puber "+tag,"isDraft":False,"isPrerelease":False, - "assets":[{"name":"puber-v1.2.3.apk"},{"name":"puber-v1.2.3.apk.sha256"}],"body":notes.read_text(),"url":"https://github.com/rovkinmax/Puber/releases/tag/"+tag} - def fake_gh(root, args, input_text=None): - if args[0:2] == ["run","view"]: - return subprocess.CompletedProcess(args, 0, json.dumps({"databaseId":42,"attempt":1,"workflowPath":".github/workflows/release.yml","headSha":target,"status":"completed","conclusion":"success","jobs":[{"status":"completed","conclusion":"success"}]}), "") - if args[0:2] == ["release","view"]: - return subprocess.CompletedProcess(args, 0, json.dumps(release), "") - raise AssertionError(args) - mod.helper.gh = fake_gh - seen=[]; mod.helper.emit=lambda transition, **fields: seen.append((transition,fields)) - assert mod.main() == 0 and seen[0][0] == "release_release_published" - try: - actual_run_tuple({"release_run": {**run, "head_sha":"b"*40}}, tag, target) - except mod.helper.ContractError: - pass - else: - raise AssertionError("switched release run was accepted") + root = Path(directory); bare = root / "origin.git" + work, target, bare_path = publish_fixture.materialize(root, bare) + graph = json.loads((work / ".kent/workflows/puber-release.json").read_text()) + publish_node = next(n for n in graph["nodes"] if n["key"] == "publish") + monitor_node = next(n for n in graph["nodes"] if n["key"] == "monitor") + task_id = "task-12345678-1234-4234-8234-123456789abc" + task = { + "actions": [], "attention_count": 0, "body": "release_type: minor\nrelease_version: 99.0.0\nrelease_tag: v99.0.0\nrelease_branch: release/99.0.0\n", + "current_nodes": [{"node_id": publish_node["id"]}], "current_scripts": [{"path": ".kent/scripts/workflow-puber-release-publish", "current_node": {"node_id": publish_node["id"]}}], + "execution_target": {"commit_oid": target}, "label_ids": [], "live_sessions": [], "project": {}, "retained_session_count": 0, + "source_workspace": {}, "status": {"node_ids": [publish_node["id"]]}, "summary": {"id": task_id, "project_id": "project-6c1b6c4e-c8e2-4170-b9f7-a7d9c4602176", "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "short_id": "PUB-99"}, + "workflow": {"workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "version": 88, "name": "Puber Release"}, "worktree_path": str(work), + } + fake = root / "fake"; fake.mkdir() + task_path = fake / "task.json"; task_path.write_text(json.dumps(task), encoding="utf-8") + kent = fake / "kent"; kent.write_text("#!/bin/sh\ncat \"$PUBER_FAKE_TASK\"\n", encoding="utf-8"); kent.chmod(0o755) + body_file = fake / "body"; body_file.write_text("", encoding="utf-8") + apk_file = fake / "apk"; apk_file.write_bytes(b"test-apk-bytes") + checksum_file = fake / "checksum"; checksum_file.write_text(__import__("hashlib").sha256(apk_file.read_bytes()).hexdigest() + " puber-v99.0.0.apk\n", encoding="utf-8") + gh = fake / "gh" + gh.write_text( + """#!/usr/bin/env python3 +import json,os,subprocess,sys +args=sys.argv[1:]; target=os.environ["PUBER_TARGET"]; tag="v99.0.0" +if args[:2] == ["release","edit"]: + open(os.environ["PUBER_BODY"],"w",encoding="utf-8").write(sys.stdin.read()); sys.exit(0) +if args[0] != "api": sys.exit(2) +endpoint=args[1] +if endpoint.endswith("/git/ref/heads/master"): + print(json.dumps({"ref":"refs/heads/master","node_id":"master","url":"https://api.github.com/ref","object":{"type":"commit","sha":target}})); sys.exit(0) +if "/pulls/" in endpoint: + print(json.dumps({"html_url":"https://github.com/rovkinmax/Puber/pull/7","state":"closed","merged":True,"merged_at":"2026-08-25T00:00:00Z","base":{"ref":"master","sha":"b"*40,"repo":{"full_name":"rovkinmax/Puber"}},"head":{"ref":"release/99.0.0","sha":"c"*40,"repo":{"full_name":"rovkinmax/Puber"}},"merge_commit_sha":target})); sys.exit(0) +if "/git/ref/tags/" in endpoint: + try: sha=subprocess.check_output(["git","--git-dir",os.environ["PUBER_BARE"],"rev-parse","refs/tags/"+tag],text=True).strip() + except subprocess.CalledProcessError: print("404",file=sys.stderr); sys.exit(1) + print(json.dumps({"ref":"refs/tags/"+tag,"node_id":"tag","url":"https://api.github.com/ref","object":{"type":"commit","sha":sha}})); sys.exit(0) +if "/actions/runs?" in endpoint: + print(json.dumps({"workflow_runs":[{"id":42,"run_attempt":1,"path":".github/workflows/release.yml","name":"Release","event":"push","head_sha":target,"head_branch":tag,"repository":{"full_name":"rovkinmax/Puber"}}]})); sys.exit(0) +if "/actions/runs/42/attempts/1/jobs" in endpoint: + print(json.dumps({"jobs":[{"id":777,"name":"Build & Publish","status":"completed","conclusion":"success"}]})); sys.exit(0) +if "/actions/runs/42/attempts/1" in endpoint: + print(json.dumps({"id":42,"run_attempt":1,"path":".github/workflows/release.yml","name":"Release","event":"push","head_sha":target,"head_branch":tag,"repository":{"full_name":"rovkinmax/Puber"},"status":"completed","conclusion":"success"})); sys.exit(0) +if "/releases/assets/1" in endpoint: + sys.stdout.buffer.write(open(os.environ["PUBER_APK"],"rb").read()); sys.exit(0) +if "/releases/assets/2" in endpoint: + sys.stdout.buffer.write(open(os.environ["PUBER_CHECKSUM"],"rb").read()); sys.exit(0) +if "/releases/tags/" in endpoint: + apk=open(os.environ["PUBER_APK"],"rb").read(); checksum=open(os.environ["PUBER_CHECKSUM"],"rb").read() + print(json.dumps({"tag_name":tag,"target_commitish":target,"name":"Puber "+tag,"draft":False,"prerelease":False,"html_url":"https://github.com/rovkinmax/Puber/releases/tag/"+tag,"body":open(os.environ["PUBER_BODY"],encoding="utf-8").read(),"assets":[{"id":1,"name":"puber-"+tag+".apk","size":len(apk),"browser_download_url":"https://example.invalid/apk"},{"id":2,"name":"puber-"+tag+".apk.sha256","size":len(checksum),"browser_download_url":"https://example.invalid/checksum"}]})); sys.exit(0) +sys.exit(3) +""", + encoding="utf-8", + ); gh.chmod(0o755) + env = {**os.environ, "KENT_ENGINEERING_KIT_ROOT": str(publish_fixture.KIT), "PUBER_KENT_BIN": str(kent), "PUBER_GH_BIN": str(gh), "PUBER_FAKE_TASK": str(task_path), "PUBER_TARGET": target, "PUBER_BARE": bare_path, "PUBER_BODY": str(body_file), "PUBER_APK": str(apk_file), "PUBER_CHECKSUM": str(checksum_file)} + publish_payload = {"_kent":{"task_id":task_id,"node_id":publish_node["id"],"transition_branch_key":"merge_watch_pr_merged"},"workspace_path":str(work),"operation_id":"a"*64,"pr_url":"https://github.com/rovkinmax/Puber/pull/7","branch_name":"release/99.0.0","merge_strategy":"auto","pr_head_oid":"c"*40,"pr_base_oid":"b"*40} + published = json.loads(subprocess.run([str(work/".kent/scripts/workflow-puber-release-publish")], cwd=work, input=json.dumps(publish_payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env).stdout) + task.update({"current_nodes":[{"node_id":monitor_node["id"]}],"current_scripts":[{"path":".kent/scripts/workflow-wait-github-release","current_node":{"node_id":monitor_node["id"]}}],"status":{"node_ids":[monitor_node["id"]]}}) + task_path.write_text(json.dumps(task), encoding="utf-8") + monitor_payload = {"_kent":{"task_id":task_id,"node_id":monitor_node["id"],"transition_branch_key":"monitor_release"}} + monitor_payload.update({key: published[key] for key in ("release_version","release_tag","target_commit","pr_url","tag_push_status","release_notes_path","publication_report","publication_report_digest","release_run")}) + result = subprocess.run([str(work/".kent/scripts/workflow-wait-github-release")], cwd=work, input=json.dumps(monitor_payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert result.returncode == 0, result.stderr + output = json.loads(result.stdout) + assert output["transition"] == "release_release_published" + assert json.loads(output["publication_report"])["unresolved"] is False + assert json.loads(output["release_report"])["unresolved"] is False + assert body_file.read_text(encoding="utf-8").startswith("puber_release_notes_v1") print("workflow-wait-github-release: passed") diff --git a/.kent/scripts/workflow-puber-release-intent b/.kent/scripts/workflow-puber-release-intent index e800fdd3..984ead4f 100755 --- a/.kent/scripts/workflow-puber-release-intent +++ b/.kent/scripts/workflow-puber-release-intent @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Fail-closed Puber release runtime shared by all S05 Script nodes.""" from __future__ import annotations -import hashlib, importlib, json, os, re, shutil, subprocess, sys, tempfile, tomllib +import hashlib, importlib, json, os, re, shutil, stat, subprocess, sys, tempfile, tomllib from pathlib import Path from typing import Any, Mapping from urllib.parse import urlsplit @@ -15,6 +15,7 @@ PROJECT_ID = "project-6c1b6c4e-c8e2-4170-b9f7-a7d9c4602176" WORKFLOW_ID = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" WORKFLOW_NAME = "Puber Release" WORKFLOW_REVISION = 88 +REPOSITORY = "rovkinmax/Puber" SCRIPT_PATHS = { "release_intent_gate": ".kent/scripts/workflow-puber-release-intent", "publish": ".kent/scripts/workflow-puber-release-publish", @@ -25,7 +26,17 @@ TASK_ROOT_KEYS = {"actions","attention_count","body","current_nodes","current_sc KENT_KEYS = {"task_id","node_id","transition_branch_key"} SHA1 = re.compile(r"^[0-9a-f]{40}$") TASK_ID = re.compile(r"^task-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$") +TASK_SHORT_ID = re.compile(r"^[A-Z][A-Z0-9]+-[0-9]+$") SEMVER = re.compile(r"^\d+\.\d+\.\d+$") +RELEASE_TYPE = {"minor", "patch", "major"} +RELEASE_BRANCH = re.compile(r"^release/\d+\.\d+\.\d+$") +TAG = re.compile(r"^v\d+\.\d+\.\d+$") +HEX64 = re.compile(r"^[0-9a-f]{64}$") +NOT_SELECTED_RUN = {"schema": "puber_release_run_v1", "state": "not_selected"} +RUN_KEYS = {"schema", "state", "repository", "workflow_path", "workflow_name", "event", "run_id", "attempt", "head_sha", "ref", "head_branch"} +PUBLICATION_KEYS = {"schema", "operation_digest", "pr_url", "release_version", "release_tag", "target_commit", "notes_report", "notes_digest", "tag_phase", "tag_status", "unresolved"} +RELEASE_KEYS = {"schema", "run", "jobs", "release", "assets", "checksum", "notes_digest", "publication_report_digest", "unresolved"} +CLEANUP_KEYS = {"schema", "operation_digest", "cleanup_session_id", "cleanup_mode", "publication_report", "publication_report_digest", "release_report", "release_report_digest", "release_notes_path", "release_notes_digest", "unresolved", "destructive_action", "blocker_reason"} class ContractError(ValueError): pass @@ -52,6 +63,42 @@ def require_string(value: Any, label: str, *, empty: bool = False) -> str: def sha256_bytes(raw: bytes) -> str: return hashlib.sha256(raw).hexdigest() +def canonical_bytes(value: Any) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + +def canonical_json(value: Any) -> str: + return canonical_bytes(value).decode("utf-8") + +def digest_json(value: Any) -> str: + return sha256_bytes(canonical_bytes(value)) + +def parse_closed_json(raw: Any, schema: str, keys: set[str]) -> dict[str, Any]: + if not isinstance(raw, str) or len(raw.encode("utf-8")) > 2 * 1024 * 1024: + raise ContractError(f"{schema} must be a bounded JSON string") + value = _json_one(raw.encode("utf-8")) + if set(value) != keys or value.get("schema") != schema: + raise ContractError(f"{schema} has an incomplete, extra, or stale field set") + return value + +def _safe_task_short_id(value: Any) -> str: + value = require_string(value, "task_short_id") + if not TASK_SHORT_ID.fullmatch(value): + raise ContractError("task_short_id is not safe") + return value + +def safe_version_tag(version: Any, tag: Any) -> tuple[str, str]: + version = require_string(version, "release_version") + tag = require_string(tag, "release_tag") + if not SEMVER.fullmatch(version) or not TAG.fullmatch(tag) or tag != f"v{version}": + raise ContractError("release version/tag is not exact") + return version, tag + +def safe_release_branch(value: Any, version: str) -> str: + branch = require_string(value, "branch_name") + if not RELEASE_BRANCH.fullmatch(branch) or branch != f"release/{version}": + raise ContractError("release branch is not exact") + return branch + def child_env() -> dict[str, str]: env = dict(os.environ) for key in ("KENT_SESSION_ID", "KENT_RUN_ID", "KENT_STEP_ID"): @@ -64,6 +111,13 @@ def run(command: list[str], *, cwd: Path, env: Mapping[str, str] | None = None, raise ContractError(result.stderr.strip() or result.stdout.strip() or f"command failed: {' '.join(command)}") return result +def run_bytes(command: list[str], *, cwd: Path, env: Mapping[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[bytes]: + result = subprocess.run(command, cwd=cwd, env=dict(env) if env is not None else None, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if check and result.returncode != 0: + message = result.stderr.decode("utf-8", "replace").strip() or result.stdout.decode("utf-8", "replace").strip() + raise ContractError(message or f"command failed: {' '.join(command)}") + return result + def git(root: Path, *args: str, check: bool = True) -> str: return run(["git", *args], cwd=root, check=check).stdout.strip() @@ -94,7 +148,7 @@ def task_show(root: Path, task_id: str) -> dict[str, Any]: raise ContractError("Kent task show failed") return _json_one(result.stdout.encode("utf-8")) -def validate_task(root: Path, task_id: str, node_key: str, task: dict[str, Any] | None = None) -> dict[str, Any]: +def validate_task(root: Path, task_id: str, node_key: str, task: dict[str, Any] | None = None, *, allowed_notes_path: str | None = None) -> dict[str, Any]: if not TASK_ID.fullmatch(task_id): raise ContractError("_kent.task_id is invalid") task = task if task is not None else task_show(root, task_id) @@ -121,9 +175,13 @@ def validate_task(root: Path, task_id: str, node_key: str, task: dict[str, Any] commit = require_string(execution.get("commit_oid"), "execution_target.commit_oid") if not SHA1.fullmatch(commit) or git(root, "rev-parse", "HEAD") != commit: raise ContractError("selected checkout is not the exact Task execution commit") - if git(root, "status", "--porcelain=v1", "--untracked-files=all"): - raise ContractError("selected checkout is not clean") - return {"task": task, "task_id": task_id, "task_short_id": require_string(summary.get("short_id"), "task short id"), "node_id": node_id, "commit": commit} + status = git(root, "status", "--porcelain=v1", "--untracked-files=all") + if status: + allowed = validate_notes_path(root, allowed_notes_path, task_short_id=_safe_task_short_id(summary.get("short_id")), require_exists=False) if allowed_notes_path else None + lines = [line for line in status.splitlines() if line] + if not allowed or any(line not in {f"?? {allowed}", f"?? {allowed}/"} for line in lines): + raise ContractError("selected checkout is not clean") + return {"task": task, "task_id": task_id, "task_short_id": _safe_task_short_id(summary.get("short_id")), "node_id": node_id, "commit": commit} def validate_envelope(payload: Mapping[str, Any], root: Path, node_key: str, *, task: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: if not isinstance(payload, Mapping): @@ -139,7 +197,35 @@ def validate_envelope(payload: Mapping[str, Any], root: Path, node_key: str, *, expected = {"_kent", *(item.get("key") for item in edge.get("parameters", []))} if None in expected or set(payload) != expected: raise ContractError(f"input fields do not exactly match graph edge {branch}") - return dict(payload), validate_task(root, task_id, node_key, task), edge + allowed_notes = payload.get("release_notes_path") if isinstance(payload.get("release_notes_path"), str) else None + return dict(payload), validate_task(root, task_id, node_key, task, allowed_notes_path=allowed_notes), edge + +def validate_notes_path(root: Path, value: Any, *, task_short_id: str | None = None, require_exists: bool = True) -> str: + path = require_string(value, "release_notes_path") + if Path(path).is_absolute() or "\\" in path or Path(path).as_posix() != path: + raise ContractError("release notes path must be relative and canonical") + expected_prefix = f".todo/{_safe_task_short_id(task_short_id)}/" if task_short_id else ".todo/" + if not path.startswith(expected_prefix) or Path(path).name != "release-notes-ru.md" or len(Path(path).parts) != len(Path(expected_prefix).parts) + 1: + raise ContractError("release notes path is outside the expected task-local root") + candidate = root / path + if candidate.is_symlink() or (require_exists and (not candidate.is_file() or not stat.S_ISREG(candidate.stat().st_mode))): + raise ContractError("release notes path is not a regular file") + return path + +def output_edge(root: Path, transition: str, target_node: str) -> dict[str, Any]: + graph = json.loads((root / ".kent/workflows/puber-release.json").read_text(encoding="utf-8")) + target_id = _node_id(root, target_node) + matches = [edge for edge in graph.get("edges", []) if edge.get("key") == transition and edge.get("target_node_id") == target_id] + if len(matches) != 1: + raise ContractError(f"output transition is not an exact graph edge: {transition}") + return matches[0] + +def emit_exact(root: Path, transition: str, target_node: str, fields: Mapping[str, Any]) -> None: + edge = output_edge(root, transition, target_node) + expected = {item.get("key") for item in edge.get("parameters", [])} + if set(fields) != expected: + raise ContractError(f"output fields do not exactly match graph edge {transition}") + print(json.dumps({"transition": transition, **dict(fields)}, ensure_ascii=False, sort_keys=True)) def _kit_modules() -> dict[str, Any]: configured = os.environ.get("KENT_ENGINEERING_KIT_ROOT", "") @@ -169,49 +255,86 @@ def _external_captures(root: Path, profile: Any, selected: Any) -> list[tuple[st raise ContractError("runtime external root descriptor is unsupported") raw = path.read_bytes() if sha256_bytes(raw) != digest: - raise ContractError(f"runtime external source drifted: {external.kind}") + raise ContractError(f"runtime external source drifted: {external.kind}:{external.key}") captures.append((external.kind, external.key, raw)) return captures +def _ruby_yaml(path: Path) -> dict[str, Any]: + script = 'require "yaml"; require "json"; value=YAML.safe_load(File.read(ARGV.fetch(0)), aliases: false); value["on"]=value.delete(true) if value.is_a?(Hash)&&value.key?(true); puts JSON.generate(value)' + result = subprocess.run(["ruby", "-e", script, str(path)], cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if result.returncode: + raise ContractError(f"workflow source cannot be parsed: {path}") + value = json.loads(result.stdout) + if not isinstance(value, dict): + raise ContractError(f"workflow source is not an object: {path}") + return value + +def _secret_refs(value: Any) -> list[str]: + found: set[str] = set() + pattern = re.compile(r"(?:\$\{\{\s*)?secrets\.([A-Za-z_][A-Za-z0-9_-]*)(?:\s*\}\})?") + def visit(item: Any) -> None: + if isinstance(item, str): + found.update(match.group(1) for match in pattern.finditer(item)) + elif isinstance(item, dict): + for key, nested in item.items(): + visit(key); visit(nested) + elif isinstance(item, list): + for nested in item: + visit(nested) + visit(value) + return sorted(found) + +def _normalized_workflow(root: Path, path: str) -> dict[str, Any]: + raw = _ruby_yaml(root / path) + events_raw = raw.get("on", {}) + if isinstance(events_raw, dict): + event_items = events_raw.items() + elif isinstance(events_raw, list): + event_items = ((name, {}) for name in events_raw) + else: + event_items = ((str(events_raw), {}),) + events = [] + for name, config in event_items: + config = config if isinstance(config, dict) else {} + inputs = config.get("inputs", {}) or {} + dispatch_inputs = [] + for input_name, input_value in sorted(inputs.items()) if isinstance(inputs, dict) else []: + input_value = input_value if isinstance(input_value, dict) else {} + item = {"name": input_name, "type": input_value.get("type", "string"), "required": bool(input_value.get("required", False)), "default_present": "default" in input_value} + if "default" in input_value: + item["default"] = input_value["default"] + dispatch_inputs.append(item) + events.append({"name": name, "branches": config.get("branches", []) or [], "branches_ignore": config.get("branches-ignore", []) or [], "tags": config.get("tags", []) or [], "tags_ignore": config.get("tags-ignore", []) or [], "paths": config.get("paths", []) or [], "paths_ignore": config.get("paths-ignore", []) or [], "types": sorted(config.get("types", []) or []), "dispatch_inputs": dispatch_inputs}) + workflow_env = raw.get("env", {}) or {} + workflow_permissions = raw.get("permissions", {}) or {} + defaults = (raw.get("defaults", {}) or {}).get("run", {}) or {} + jobs = [] + raw_jobs = raw.get("jobs") + if not isinstance(raw_jobs, dict): + raise ContractError(f"workflow source has no jobs: {path}") + for job_key, raw_job in raw_jobs.items(): + job = raw_job if isinstance(raw_job, dict) else {} + job_env = {**workflow_env, **(job.get("env", {}) or {})} + permissions = job.get("permissions", workflow_permissions) or {} + job_defaults = {**defaults, **((job.get("defaults", {}) or {}).get("run", {}) or {})} + steps = [] + checkout_persist_credentials = False + for raw_step in job.get("steps", []) or []: + if not isinstance(raw_step, dict): + raise ContractError(f"workflow step is not an object: {path}:{job_key}") + uses = raw_step.get("uses", "") + with_values = raw_step.get("with", {}) or {} + if uses.startswith("actions/checkout@"): + checkout_persist_credentials = with_values.get("persist-credentials", True) + step_env = raw_step.get("env", {}) or {} + steps.append({"kind": "uses" if uses else "run", "name": raw_step.get("name", ""), "condition": raw_step.get("if", ""), "continue_on_error": bool(raw_step.get("continue-on-error", False)), "uses": uses, "with": with_values, "run": raw_step.get("run", ""), "effective_shell": job_defaults.get("shell", ""), "effective_working_directory": job_defaults.get("working-directory", ""), "effective_environment": step_env, "secret_refs": _secret_refs({"condition": raw_step.get("if", ""), "uses": uses, "with": with_values, "run": raw_step.get("run", ""), "effective_environment": step_env})}) + needs = job.get("needs", []) + jobs.append({"job_key": job_key, "job_display_name": job.get("name", job_key), "needs": [needs] if isinstance(needs, str) else needs, "matrix": (job.get("strategy", {}) or {}).get("matrix", {}) or {}, "condition": job.get("if", ""), "continue_on_error": bool(job.get("continue-on-error", False)), "runs_on": job.get("runs-on", ""), "runner_environment_asserted": any(step.get("run", "").strip() == 'test "${RUNNER_ENVIRONMENT:-github-hosted}" = github-hosted' for step in steps), "effective_permissions": permissions, "effective_defaults_run": {"shell": job_defaults.get("shell", ""), "working_directory": job_defaults.get("working-directory", "")}, "github_environment": job.get("environment", ""), "services": job.get("services", {}) or {}, "container": job.get("container"), "checkout_persist_credentials": bool(checkout_persist_credentials), "secret_refs": _secret_refs({"condition": job.get("if", ""), "env": job_env, "steps": steps}), "effective_environment": job_env, "steps": steps}) + return {"schema": "normalized_github_workflow_source_v1", "workflow_path": path, "workflow_display_name": raw.get("name", ""), "events": events, "permissions": workflow_permissions, "environment": workflow_env, "defaults_run": {"shell": defaults.get("shell", ""), "working_directory": defaults.get("working-directory", "")}, "jobs": jobs} + def _normalized_sources(root: Path, kit: dict[str, Any], spec: Any) -> list[Any]: - rows = list(spec.required_jobs_v1.jobs) + list(spec.effect_jobs_v1.jobs) - output = [] - for path in sorted({row["workflow_path"] for row in rows}): - text = (root / path).read_text(encoding="utf-8") - selected = [row for row in rows if row["workflow_path"] == path] - if path.endswith("pr-checks.yml"): - if "name: PR Checks" not in text or "workflow_dispatch:" in text or any(command not in text for command in (":app:detektAll",":app:testProdDebugUnitTest",":app:assembleProdDebug")): - raise ContractError("PR Checks source/job identity drifted") - if "pull_request:" not in text or "branches: [ master ]" not in text or "permissions:\n contents: read" not in text: - raise ContractError("PR Checks event or permission source drifted") - name, permissions = "PR Checks", {"contents":"read"} - elif path.endswith("release.yml"): - if "name: Release" not in text or "workflow_dispatch:" in text or "--generate-notes=false" not in text or '--notes ""' not in text: - raise ContractError("Release source/job identity drifted") - if "push:" not in text or "tags:" not in text or "permissions:\n contents: write" not in text: - raise ContractError("Release event or permission source drifted") - name, permissions = "Release", {"contents":"write"} - else: - raise ContractError(f"unexpected workflow source: {path}") - jobs, events = [], [] - for row in selected: - if f" {row['job_key']}:" not in text or f"name: {row['job_display_name']}" not in text or f"runs-on: {row['runs_on']}" not in text: - raise ContractError(f"workflow job identity drifted: {row['job_key']}") - for step in row["steps"]: - if step["kind"] == "uses" and f"uses: {step['uses']}" not in text: - raise ContractError(f"workflow action drifted: {row['job_key']}:{step['name']}") - if step["kind"] == "run" and step["run"] and step["run"] not in text: - release_checksum = row["job_key"] == "release" and "checksum" in step["name"].lower() and {"sha256sum", "release-artifacts"} <= set(re.findall(r"[A-Za-z0-9_.-]+", text)) - release_create = row["job_key"] == "release" and "release" in step["name"].lower() and {"gh", "release", "create"} <= set(re.findall(r"[A-Za-z0-9_.-]+", text)) and "--generate-notes=false" in text - if not (release_checksum or release_create): - raise ContractError(f"workflow command drifted: {row['job_key']}:{step['name']}") - job = {key: row[key] for key in ("job_key","job_display_name","needs","matrix","condition","continue_on_error","runs_on","runner_environment_asserted","effective_permissions","effective_defaults_run","github_environment","services","container","checkout_persist_credentials","secret_refs","effective_environment","steps")} - job["steps"] = [{key: step[key] for key in ("kind","name","condition","continue_on_error","uses","with","run","effective_shell","effective_working_directory","effective_environment","secret_refs")} for step in row["steps"]] - jobs.append(job) - if row["event_selector"] not in events: - events.append(row["event_selector"]) - output.append(kit["NormalizedGitHubWorkflowSourceV1"].from_dict({"schema":"normalized_github_workflow_source_v1","workflow_path":path,"workflow_display_name":name,"events":events,"permissions":permissions,"environment":{},"defaults_run":{"shell":"","working_directory":""},"jobs":jobs})) - return output + paths = sorted({row["workflow_path"] for table in (spec.required_jobs_v1, spec.qualification_jobs_v1, spec.effect_jobs_v1) for row in table.jobs}) + return [kit["NormalizedGitHubWorkflowSourceV1"].from_dict(_normalized_workflow(root, path)) for path in paths] def _load_release_spec(root: Path, kit: dict[str, Any], profile: Any) -> Any: raw = tomllib.loads((root / profile.release.spec_path).read_text(encoding="utf-8")) @@ -271,89 +394,315 @@ def canonical_pr_url(value: Any) -> str: return url def remote_master(root: Path) -> str: - value = git(root, "rev-parse", "refs/remotes/origin/master", check=False) - if not SHA1.fullmatch(value): - raise ContractError("current remote master is unavailable") - return value + result = gh_api(root, f"repos/{REPOSITORY}/git/ref/heads/master") + if not isinstance(result, dict) or set(result) - {"ref", "node_id", "url", "object"} or result.get("ref") != "refs/heads/master": + raise ContractError("remote master response is not exact") + obj = result.get("object") + if not isinstance(obj, dict) or obj.get("type") != "commit" or not SHA1.fullmatch(str(obj.get("sha", ""))): + raise ContractError("remote master does not resolve to an exact commit") + return obj["sha"] def gh(root: Path, args: list[str], *, input_text: str | None = None) -> subprocess.CompletedProcess[str]: return run([os.environ.get("PUBER_GH_BIN", shutil.which("gh") or "gh"), *args], cwd=root, env=child_env(), check=False, input_text=input_text) -def pr_state(root: Path, url: str) -> dict[str, Any]: - result = gh(root, ["pr","view",url,"--json","state,mergedAt,mergeCommit,headRefName,headRefOid,baseRefName,baseRefOid,isCrossRepository,url"]) +def gh_api(root: Path, endpoint: str, *, headers: list[str] | None = None, allow_404: bool = False) -> Any: + args = ["api", endpoint] + for header in headers or []: + args.extend(["--header", header]) + result = gh(root, args) if result.returncode != 0: - raise ContractError("GitHub PR query failed") - value = _json_one(result.stdout.encode("utf-8")) - if value.get("state") != "MERGED" or not value.get("mergedAt") or value.get("baseRefName") != "master" or value.get("isCrossRepository") is not False or value.get("url") != url: - raise ContractError("PR is not exact merged same-repository master evidence") - return value + if allow_404 and "404" in (result.stderr or ""): + return None + raise ContractError(f"GitHub API request failed: {endpoint}") + return _json_one(result.stdout.encode("utf-8")) + +def gh_api_bytes(root: Path, endpoint: str) -> bytes: + binary = run_bytes([os.environ.get("PUBER_GH_BIN", shutil.which("gh") or "gh"), "api", endpoint, "--header", "Accept: application/octet-stream"], cwd=root, env=child_env(), check=False) + if binary.returncode != 0: + raise ContractError(f"GitHub asset download failed: {endpoint}") + return binary.stdout + +def pr_state(root: Path, url: str) -> dict[str, Any]: + number = int(urlsplit(url).path.rsplit("/", 1)[1]) + value = gh_api(root, f"repos/{REPOSITORY}/pulls/{number}") + if not isinstance(value, dict): + raise ContractError("PR API response is not an object") + base, head, repo = value.get("base"), value.get("head"), value.get("base", {}).get("repo") if isinstance(value.get("base"), dict) else None + if value.get("html_url") != url or value.get("state") != "closed" or value.get("merged_at") is None or value.get("merged") is not True: + raise ContractError("PR is not exact merged evidence") + if not isinstance(base, dict) or not isinstance(head, dict) or not isinstance(repo, dict) or base.get("ref") != "master" or base.get("repo", {}).get("full_name") != REPOSITORY or head.get("repo", {}).get("full_name") != REPOSITORY: + raise ContractError("PR is not same-repository master evidence") + if not SHA1.fullmatch(str(head.get("sha", ""))) or not SHA1.fullmatch(str(base.get("sha", ""))) or not SHA1.fullmatch(str(value.get("merge_commit_sha", ""))): + raise ContractError("PR OID evidence is not exact") + return {"url": url, "state": "MERGED", "mergedAt": value["merged_at"], "headRefName": head.get("ref"), "headRefOid": head["sha"], "baseRefName": base["ref"], "baseRefOid": base["sha"], "mergeCommit": {"oid": value["merge_commit_sha"]}, "isCrossRepository": False} def local_tag(root: Path, tag: str) -> str: value = git(root, "rev-parse", f"refs/tags/{tag}^{{commit}}", check=False) return value if SHA1.fullmatch(value) else "" def remote_tag(root: Path, tag: str) -> str: - output = git(root, "ls-remote", "--tags", "origin", f"refs/tags/{tag}", f"refs/tags/{tag}^{{}}", check=False) - values = [line.split()[0] for line in output.splitlines() if line.split() and SHA1.fullmatch(line.split()[0])] - return values[-1] if values else "" + value = gh_api(root, f"repos/{REPOSITORY}/git/ref/tags/{tag}", allow_404=True) + if value is None: + return "" + if not isinstance(value, dict) or value.get("ref") != f"refs/tags/{tag}" or not isinstance(value.get("object"), dict): + raise ContractError("remote tag response is not exact") + obj = value["object"] + if obj.get("type") == "commit" and SHA1.fullmatch(str(obj.get("sha", ""))): + return obj["sha"] + if obj.get("type") == "tag" and SHA1.fullmatch(str(obj.get("sha", ""))): + annotated = gh_api(root, f"repos/{REPOSITORY}/git/tags/{obj['sha']}") + if isinstance(annotated, dict) and isinstance(annotated.get("object"), dict) and annotated["object"].get("type") == "commit" and SHA1.fullmatch(str(annotated["object"].get("sha", ""))): + return annotated["object"]["sha"] + raise ContractError("remote tag does not resolve to an exact commit") + +def remote_release_tags(root: Path) -> list[str]: + output = git(root, "ls-remote", "--tags", "origin", "refs/tags/v*", check=False) + tags = set() + for line in output.splitlines(): + fields = line.split() + if len(fields) >= 2: + name = fields[1].removeprefix("refs/tags/").removesuffix("^{}") + if TAG.fullmatch(name): + tags.add(name) + return sorted(tags, key=lambda value: tuple(int(part) for part in value[1:].split("."))) + +def parse_run_carrier(raw: Any) -> dict[str, Any]: + if isinstance(raw, str): + value = _json_one(raw.encode("utf-8")) + elif isinstance(raw, dict): + value = raw + else: + raise ContractError("release_run carrier is not an object or JSON object") + if value == NOT_SELECTED_RUN: + return dict(value) + if set(value) != RUN_KEYS or value.get("schema") != "puber_release_run_v1" or value.get("state") != "selected": + raise ContractError("release_run carrier is not closed") + if value.get("repository") != REPOSITORY or value.get("workflow_path") != ".github/workflows/release.yml" or value.get("workflow_name") != "Release" or value.get("event") != "push": + raise ContractError("release_run carrier workflow identity drifted") + if not isinstance(value.get("run_id"), int) or value["run_id"] <= 0 or not isinstance(value.get("attempt"), int) or value["attempt"] <= 0: + raise ContractError("release_run carrier IDs are invalid") + if not SHA1.fullmatch(str(value.get("head_sha", ""))) or not TAG.fullmatch(str(value.get("head_branch", ""))) or value.get("ref") != f"refs/tags/{value['head_branch']}": + raise ContractError("release_run carrier ref/head is invalid") + return dict(value) + +def selected_run_context(run: Mapping[str, Any]) -> dict[str, Any]: + return {key: run[key] for key in ("repository", "workflow_path", "workflow_name", "event", "run_id", "attempt", "head_sha", "ref")} + +def parse_notes_report(raw: Any, root: Path, task_short_id: str, *, expected_tag: str | None = None, expected_target: str | None = None) -> dict[str, Any]: + value = raw if isinstance(raw, dict) else parse_closed_json(raw, "puber_release_notes_v1", {"schema", "path", "mode", "bytes", "sha256", "encoding", "line_endings", "final_newline", "producer", "previous_tag", "range", "target_commit"}) + if set(value) != {"schema", "path", "mode", "bytes", "sha256", "encoding", "line_endings", "final_newline", "producer", "previous_tag", "range", "target_commit"} or value.get("schema") != "puber_release_notes_v1": + raise ContractError("notes report is not closed") + path = validate_notes_path(root, value.get("path"), task_short_id=task_short_id) + if value.get("mode") != 0o600 or not isinstance(value.get("bytes"), int) or value["bytes"] <= 0 or not HEX64.fullmatch(str(value.get("sha256", ""))) or value.get("encoding") != "UTF-8" or value.get("line_endings") != "LF" or value.get("final_newline") is not True or value.get("producer") != ".kent/scripts/workflow-puber-release-publish": + raise ContractError("notes report metadata is not canonical") + target = require_string(value.get("target_commit"), "notes target_commit") + if not SHA1.fullmatch(target) or expected_target and target != expected_target: + raise ContractError("notes target commit is not exact") + if expected_tag and value.get("range") != (f"{value['previous_tag']}..{target}" if value.get("previous_tag") else target): + raise ContractError("notes range is not exact") + body = (root / path).read_bytes() + mode = stat.S_IMODE((root / path).stat().st_mode) + if mode != 0o600 or len(body) != value["bytes"] or sha256_bytes(body) != value["sha256"] or b"\r" in body or not body.endswith(b"\n") or not re.search(r"[А-Яа-яЁё]", body.decode("utf-8")): + raise ContractError("notes bytes/readback are not canonical") + return dict(value) + +def publication_report(operation_digest: str, pr_url: str, version: str, tag: str, target: str, notes: Mapping[str, Any], *, tag_phase: str, tag_status: str, unresolved: bool) -> tuple[str, str]: + if not HEX64.fullmatch(operation_digest): + raise ContractError("operation digest is not exact") + report = {"schema": "puber_publication_report_v1", "operation_digest": operation_digest, "pr_url": pr_url, "release_version": version, "release_tag": tag, "target_commit": target, "notes_report": dict(notes), "notes_digest": notes["sha256"], "tag_phase": tag_phase, "tag_status": tag_status, "unresolved": unresolved} + encoded = canonical_json(report) + return encoded, sha256_bytes(encoded.encode("utf-8")) + +def parse_publication_report(raw: Any, digest: Any, *, expected_pr: str | None = None, expected_version: str | None = None, expected_tag: str | None = None, expected_target: str | None = None, task_short_id: str | None = None, root: Path | None = None) -> dict[str, Any]: + value = raw if isinstance(raw, dict) else parse_closed_json(raw, "puber_publication_report_v1", PUBLICATION_KEYS) + if set(value) != PUBLICATION_KEYS or value.get("schema") != "puber_publication_report_v1": + raise ContractError("publication report is not closed") + if not isinstance(digest, str) or digest != sha256_bytes(canonical_json(value).encode("utf-8")) or not HEX64.fullmatch(digest): + raise ContractError("publication report digest mismatch") + safe_version_tag(value.get("release_version"), value.get("release_tag")) + if expected_version and value["release_version"] != expected_version or expected_tag and value["release_tag"] != expected_tag: + raise ContractError("publication report version/tag drifted") + if expected_pr and value["pr_url"] != expected_pr or expected_target and value["target_commit"] != expected_target: + raise ContractError("publication report PR/target drifted") + if not SHA1.fullmatch(str(value.get("target_commit", ""))) or value.get("pr_url") and canonical_pr_url(value["pr_url"]) != value["pr_url"]: + raise ContractError("publication report identity is invalid") + if value.get("tag_phase") not in {"prepared", "tag_published"} or not isinstance(value.get("tag_status"), str) or not isinstance(value.get("unresolved"), bool): + raise ContractError("publication report phase is invalid") + if not isinstance(value.get("notes_report"), dict): + raise ContractError("publication report notes report is not an object") + if root and task_short_id: + parse_notes_report(value["notes_report"], root, task_short_id, expected_tag=value["release_tag"], expected_target=value["target_commit"]) + if value.get("notes_digest") != value["notes_report"].get("sha256"): + raise ContractError("publication report notes digest mismatch") + return dict(value) + +def parse_release_report(raw: Any, digest: Any) -> dict[str, Any]: + value = raw if isinstance(raw, dict) else parse_closed_json(raw, "puber_release_report_v1", RELEASE_KEYS) + if set(value) != RELEASE_KEYS or value.get("schema") != "puber_release_report_v1" or not isinstance(digest, str) or digest != sha256_bytes(canonical_json(value).encode("utf-8")) or not isinstance(value.get("unresolved"), bool): + raise ContractError("release report is not closed or its digest mismatches") + return dict(value) def notes_report(root: Path, task_short_id: str, tag: str, target: str) -> dict[str, Any]: - path = root / ".todo" / task_short_id / "release-notes-ru.md" - previous = next((item for item in git(root, "tag", "--sort=-v:refname", check=False).splitlines() if item != tag and re.fullmatch(r"v\d+\.\d+\.\d+", item)), "") + task_short_id = _safe_task_short_id(task_short_id) + _, tag = safe_version_tag(tag[1:], tag) + if not SHA1.fullmatch(target): + raise ContractError("notes target is not exact") + path_value = f".todo/{task_short_id}/release-notes-ru.md" + path = root / path_value + previous_values = [value for value in remote_release_tags(root) if value != tag and tuple(int(part) for part in value[1:].split(".")) < tuple(int(part) for part in tag[1:].split("."))] + previous = previous_values[-1] if previous_values else "" revision = f"{previous}..{target}" if previous else target subjects = git(root, "log", "--format=%s", revision, check=False).splitlines()[:100] body = ("puber_release_notes_v1\n\n# Изменения\n\n" + "\n".join(f"- {subject}" for subject in subjects) + "\n").encode("utf-8") + if not re.search(r"[А-Яа-яЁё]", body.decode("utf-8")): + body += "- Изменения релиза подтверждены.\n".encode("utf-8") if path.exists(): - if path.read_bytes() != body: + if path.is_symlink() or not path.is_file() or path.read_bytes() != body: raise ContractError("existing canonical release notes conflict") else: path.parent.mkdir(parents=True, exist_ok=True) fd, temp = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) try: + os.chmod(temp, 0o600) with os.fdopen(fd, "wb") as stream: stream.write(body); stream.flush(); os.fsync(stream.fileno()) os.replace(temp, path) + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) finally: Path(temp).unlink(missing_ok=True) readback = path.read_bytes() - if readback != body or b"\r" in readback or not readback.endswith(b"\n") or not readback.decode("utf-8"): + if path.is_symlink() or not path.is_file() or stat.S_IMODE(path.stat().st_mode) != 0o600 or readback != body or b"\r" in readback or not readback.endswith(b"\n") or not re.search(r"[А-Яа-яЁё]", readback.decode("utf-8")): raise ContractError("canonical notes readback failed") - return {"schema":"puber_release_notes_v1","path":str(path.relative_to(root)),"bytes":len(readback),"sha256":sha256_bytes(readback),"encoding":"UTF-8","line_endings":"LF","final_newline":True,"producer":".kent/scripts/workflow-puber-release-publish","previous_tag":previous,"range":revision,"target_commit":target} + return {"schema":"puber_release_notes_v1","path":path_value,"mode":0o600,"bytes":len(readback),"sha256":sha256_bytes(readback),"encoding":"UTF-8","line_endings":"LF","final_newline":True,"producer":".kent/scripts/workflow-puber-release-publish","previous_tag":previous,"range":revision,"target_commit":target} def publish_tag(root: Path, tag: str, target: str) -> str: remote = remote_tag(root, tag) + local = local_tag(root, tag) if remote: if remote != target: raise ContractError("remote tag conflicts with exact target") + if local and local != target: + raise ContractError("local tag conflicts with exact target") return "already_present_exact" - if local_tag(root, tag): + if local and local != target: raise ContractError("local tag exists while remote tag is absent") - git(root, "tag", tag, target) - git(root, "push", "origin", f"refs/tags/{tag}") - if local_tag(root, tag) != target or remote_tag(root, tag) != target: + if not local: + git(root, "tag", tag, target) + pushed = run(["git", "push", "origin", f"refs/tags/{tag}"], cwd=root, check=False) + remote_after = remote_tag(root, tag) + if remote_after == target and local_tag(root, tag) == target: + return "push_ack_recovered" if pushed.returncode else ("created_and_pushed" if not local else "local_tag_pushed") + if pushed.returncode: + raise ContractError("tag push failed and remote readback is absent") + if local_tag(root, tag) != target or remote_after != target: raise ContractError("tag readback mismatch") return "created_and_pushed" +def discover_release_run(root: Path, tag: str, target: str) -> dict[str, Any]: + response = gh_api(root, f"repos/{REPOSITORY}/actions/runs?event=push&head_sha={target}&per_page=100") + if not isinstance(response, dict) or not isinstance(response.get("workflow_runs"), list): + raise ContractError("release run discovery response is not exact") + matches = [] + for row in response["workflow_runs"]: + if not isinstance(row, dict): + raise ContractError("release run row is not an object") + if row.get("path") == ".github/workflows/release.yml" and row.get("name") == "Release" and row.get("event") == "push" and row.get("head_sha") == target and row.get("head_branch") == tag and row.get("repository", {}).get("full_name") == REPOSITORY: + matches.append(row) + if len(matches) != 1: + raise ContractError("release run is missing or ambiguous") + row = matches[0] + if not isinstance(row.get("id"), int) or row["id"] <= 0 or not isinstance(row.get("run_attempt"), int) or row["run_attempt"] <= 0: + raise ContractError("discovered run identity is invalid") + return {"schema": "puber_release_run_v1", "state": "selected", "repository": REPOSITORY, "workflow_path": ".github/workflows/release.yml", "workflow_name": "Release", "event": "push", "run_id": row["id"], "attempt": row["run_attempt"], "head_sha": target, "ref": f"refs/tags/{tag}", "head_branch": tag} + +def read_release_run(root: Path, run: Mapping[str, Any], target: str, tag: str) -> tuple[dict[str, Any], dict[str, Any]]: + response = gh_api(root, f"repos/{REPOSITORY}/actions/runs/{run['run_id']}/attempts/{run['attempt']}") + if not isinstance(response, dict) or response.get("id") != run["run_id"] or response.get("run_attempt") != run["attempt"] or response.get("path") != ".github/workflows/release.yml" or response.get("name") != "Release" or response.get("event") != "push" or response.get("head_sha") != target or response.get("head_branch") != tag or response.get("repository", {}).get("full_name") != REPOSITORY: + raise ContractError("release run readback identity switched or drifted") + if response.get("status") != "completed" or response.get("conclusion") != "success": + raise ContractError("release run is not successful") + jobs_response = gh_api(root, f"repos/{REPOSITORY}/actions/runs/{run['run_id']}/attempts/{run['attempt']}/jobs?per_page=100") + if not isinstance(jobs_response, dict) or not isinstance(jobs_response.get("jobs"), list) or len(jobs_response["jobs"]) != 1: + raise ContractError("release jobs are missing, extra, or ambiguous") + job = jobs_response["jobs"][0] + if not isinstance(job, dict) or job.get("name") != "Build & Publish" or job.get("status") != "completed" or job.get("conclusion") != "success" or not isinstance(job.get("id"), int) or job["id"] <= 0: + raise ContractError("release job identity or outcome is invalid") + return {"run_id": run["run_id"], "attempt": run["attempt"], "path": response["path"], "name": response["name"], "event": response["event"], "head_sha": response["head_sha"], "head_branch": response["head_branch"], "status": response["status"], "conclusion": response["conclusion"]}, {"id": job["id"], "name": job["name"], "status": job["status"], "conclusion": job["conclusion"], "attempt": run["attempt"]} + +def read_release(root: Path, tag: str, target: str) -> dict[str, Any]: + release = gh_api(root, f"repos/{REPOSITORY}/releases/tags/{tag}") + if not isinstance(release, dict) or release.get("tag_name") != tag or release.get("target_commitish") != target or release.get("name") != f"Puber {tag}" or release.get("draft") is not False or release.get("prerelease") is not False or release.get("html_url") != f"https://github.com/{REPOSITORY}/releases/tag/{tag}": + raise ContractError("GitHub Release identity/state is not exact") + assets = release.get("assets") + if not isinstance(assets, list) or len(assets) != 2: + raise ContractError("GitHub Release assets are missing, extra, or ambiguous") + expected = {f"puber-{tag}.apk", f"puber-{tag}.apk.sha256"} + if {asset.get("name") for asset in assets if isinstance(asset, dict)} != expected: + raise ContractError("GitHub Release asset names are not exact") + normalized_assets = [] + for asset in assets: + if not isinstance(asset, dict) or not isinstance(asset.get("id"), int) or asset["id"] <= 0 or not isinstance(asset.get("size"), int) or asset["size"] <= 0 or not isinstance(asset.get("name"), str): + raise ContractError("GitHub Release asset identity/size is invalid") + normalized_assets.append({"id": asset["id"], "name": asset["name"], "size": asset["size"], "browser_download_url": asset.get("browser_download_url", "")}) + apk = next(item for item in normalized_assets if item["name"].endswith(".apk")) + checksum = next(item for item in normalized_assets if item["name"].endswith(".apk.sha256")) + apk_bytes = gh_api_bytes(root, f"repos/{REPOSITORY}/releases/assets/{apk['id']}") + checksum_bytes = gh_api_bytes(root, f"repos/{REPOSITORY}/releases/assets/{checksum['id']}") + if len(apk_bytes) != apk["size"] or len(checksum_bytes) != checksum["size"]: + raise ContractError("downloaded release asset sizes do not match metadata") + checksum_text = checksum_bytes.decode("utf-8") + lines = checksum_text.splitlines() + if len(lines) != 1: + raise ContractError("checksum asset is not one exact line") + match = re.fullmatch(r"([0-9a-f]{64})[ \t]+\*?(.+)", lines[0]) + if not match or match.group(2) != apk["name"] or match.group(1) != sha256_bytes(apk_bytes): + raise ContractError("checksum asset does not match APK bytes") + return {"tag_name": release["tag_name"], "target_commitish": release["target_commitish"], "name": release["name"], "draft": release["draft"], "prerelease": release["prerelease"], "html_url": release["html_url"], "body": release.get("body", ""), "assets": normalized_assets} + def report_json(value: Mapping[str, Any]) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) -def emit(transition: str, **fields: Any) -> None: - print(json.dumps({"transition":transition, **fields}, ensure_ascii=False, sort_keys=True)) - def main() -> int: try: payload, state, edge = validate_envelope(read_zero_argument(), ROOT, "release_intent_gate") + except ContractError as error: + print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr) + return 2 + values = declarations(state["task"].get("body")) + kind = values.get("release_type", "minor") + current = re.search(r"currentVersion\s*=\s*[\"'](\d+\.\d+\.\d+)[\"']", (ROOT / "app/build.gradle.kts").read_text(encoding="utf-8")) + if not current: + print(json.dumps({"error": "currentVersion is missing"}, ensure_ascii=False), file=sys.stderr) + return 2 + major, minor, patch = (int(item) for item in current.group(1).split(".")) + version = values.get("release_version") or (f"{major+1}.0.0" if kind == "major" else f"{major}.{minor}.{patch+1}" if kind == "patch" else f"{major}.{minor+1}.0") + tag = values.get("release_tag", f"v{version}") + operation_id = digest_json({"task_id": state["task_id"], "commit": state["commit"], "workflow_id": WORKFLOW_ID}) + if kind not in RELEASE_TYPE: + return _intent_failure(state, operation_id, "minor", version, "release_type must be exactly minor, patch, or major", invalid=True) + try: + safe_version_tag(version, tag) + if values.get("release_branch"): + safe_release_branch(values["release_branch"], version) attested = attest(ROOT, state, "release_intent_gate", edge["key"]) - current = re.search(r"currentVersion\s*=\s*[\"'](\d+\.\d+\.\d+)[\"']", (ROOT / "app/build.gradle.kts").read_text(encoding="utf-8")) - if not current: raise ContractError("currentVersion is missing") - values = declarations(state["task"].get("body")); kind = values.get("release_type", "minor") - major, minor, patch = (int(item) for item in current.group(1).split(".")) - version = values.get("release_version") or (f"{major+1}.0.0" if kind == "major" else f"{major}.{minor}.{patch+1}" if kind == "patch" else f"{major}.{minor+1}.0") - tag = values.get("release_tag", f"v{version}") - if not SEMVER.fullmatch(version) or tag != f"v{version}": raise ContractError("release intent version/tag is not exact") - emit("release_intent_passed", workspace_path=str(ROOT), operation_id=attested["binding"].provenance_fingerprint, task_short_id=state["task_short_id"], release_type=kind, release_version=version, release_tag=tag, source_revision=WORKFLOW_REVISION, no_live_apply=True) - except Exception as error: - emit("release_intent_blocked", blocker_reason=str(error), no_live_apply=True) + operation_id = attested["binding"].provenance_fingerprint + return _intent_success(state, operation_id, kind, version) + except ContractError as error: + return _intent_failure(state, operation_id, kind, version, str(error)) + +def _intent_success(state: dict[str, Any], operation_id: str, kind: str, version: str) -> int: + fields = {"workspace_path": str(ROOT), "operation_id": operation_id, "task_short_id": state["task_short_id"], "release_type": kind, "release_version": version} + emit_exact(ROOT, "release_intent_passed", "prepare", fields) + return 0 + +def _intent_failure(state: dict[str, Any], operation_id: str, kind: str, version: str, reason: str, *, invalid: bool = False) -> int: + transition = "release_intent_invalid" if invalid else "release_intent_blocked" + fields = {"workspace_path": str(ROOT), "operation_id": operation_id, "task_short_id": state["task_short_id"], "release_type": kind, "release_version": version, "blocker_reason": reason} + emit_exact(ROOT, transition, "release_intent_gate", fields) return 0 if __name__ == "__main__": diff --git a/.kent/scripts/workflow-puber-release-publish b/.kent/scripts/workflow-puber-release-publish index 145a8668..311cc731 100755 --- a/.kent/scripts/workflow-puber-release-publish +++ b/.kent/scripts/workflow-puber-release-publish @@ -1,49 +1,102 @@ #!/usr/bin/env python3 from __future__ import annotations + import importlib.util -from importlib.machinery import SourceFileLoader import sys +from importlib.machinery import SourceFileLoader from pathlib import Path + sys.dont_write_bytecode = True _helper_path = Path(__file__).with_name("workflow-puber-release-intent") -spec = importlib.util.spec_from_loader("puber_release_runtime", SourceFileLoader("puber_release_runtime", str(_helper_path))); helper = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(helper) +_spec = importlib.util.spec_from_loader("puber_release_runtime", SourceFileLoader("puber_release_runtime", str(_helper_path))) +helper = importlib.util.module_from_spec(_spec) +assert _spec and _spec.loader +_spec.loader.exec_module(helper) ROOT = helper.ROOT +PUBLISH_FIELDS = ( + "blocker_reason", "workspace_path", "operation_id", "pr_url", "branch_name", + "merge_strategy", "pr_head_oid", "pr_base_oid", "release_version", + "release_tag", "target_commit", "release_notes_path", "publication_report", + "publication_report_digest", "release_run", +) + +def _base_fields(payload: dict, state: dict, version: str, tag: str) -> dict: + return { + "workspace_path": str(ROOT), + "operation_id": payload.get("operation_id") or helper.digest_json({"task_id": state["task_id"], "commit": state["commit"], "workflow_id": helper.WORKFLOW_ID}), + "pr_url": payload.get("pr_url", ""), + "branch_name": payload.get("branch_name", f"release/{version}"), + "merge_strategy": payload.get("merge_strategy", ""), + "pr_head_oid": payload.get("pr_head_oid", ""), + "pr_base_oid": payload.get("pr_base_oid", ""), + "release_version": version, + "release_tag": tag, + "target_commit": payload.get("target_commit", ""), + "release_notes_path": payload.get("release_notes_path", ""), + "publication_report": payload.get("publication_report", ""), + "publication_report_digest": payload.get("publication_report_digest", ""), + "release_run": payload.get("release_run", helper.canonical_json(helper.NOT_SELECTED_RUN)), + } + def main() -> int: payload = None - derived = {} try: payload, state, edge = helper.validate_envelope(helper.read_zero_argument(), ROOT, "publish") - branch = edge["key"] - values = helper.declarations(state["task"].get("body")) - if branch == "publish_needs_user_action": - helper.emit("publish_needs_user_action", **{key: payload[key] for key in payload if key != "_kent"}, no_live_apply=True) - return 0 + except helper.ContractError as error: + print(helper.json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr) + return 2 + values = helper.declarations(state["task"].get("body")) + try: + version, tag = helper.safe_version_tag(values.get("release_version"), values.get("release_tag") or f"v{values.get('release_version', '')}") + helper.safe_release_branch(payload.get("branch_name", f"release/{version}"), version) pr_url = helper.canonical_pr_url(payload["pr_url"]) - version = values.get("release_version"); tag = values.get("release_tag") or (f"v{version}" if version else "") - derived.update({"release_version": version or "", "release_tag": tag}) + base = _base_fields(payload, state, version, tag) + if not helper.HEX64.fullmatch(base["operation_id"]): + raise helper.ContractError("operation_id is not a canonical digest") + base["release_run"] = helper.canonical_json(helper.parse_run_carrier(base["release_run"])) + if bool(payload.get("publication_report")) != bool(payload.get("publication_report_digest")): + raise helper.ContractError("publication report and digest must be paired") pr = helper.pr_state(ROOT, pr_url) - if pr.get("headRefName") != payload["branch_name"] or pr.get("headRefOid") != payload["pr_head_oid"] or pr.get("baseRefOid") != payload["pr_base_oid"]: + if pr["headRefName"] != base["branch_name"] or pr["headRefOid"] != base["pr_head_oid"] or pr["baseRefOid"] != base["pr_base_oid"]: raise helper.ContractError("merged PR head/base identity drifted") - merge_commit = pr.get("mergeCommit"); target = merge_commit.get("oid") if isinstance(merge_commit, dict) else merge_commit - if not isinstance(target, str) or not helper.SHA1.fullmatch(target) or helper.remote_master(ROOT) != target or helper.git(ROOT, "rev-parse", "HEAD") != target: + target = pr["mergeCommit"]["oid"] + if helper.remote_master(ROOT) != target or helper.git(ROOT, "rev-parse", "HEAD") != target: raise helper.ContractError("merged target is not exact current remote master and local checkout") - derived["target_commit"] = target - if not version or not helper.SEMVER.fullmatch(version) or tag != f"v{version}": - raise helper.ContractError("release version/tag is not an immutable Task declaration") - derived.update({"release_version": version, "release_tag": tag}) - attested = helper.attest(ROOT, state, "publish", branch, kind="publish", project_fields={"release_tag":tag,"target_commit":target,"release_version":version}) - notes = helper.notes_report(ROOT, state["task_short_id"], tag, target) - derived["release_notes_path"] = notes["path"] - tag_status = helper.publish_tag(ROOT, tag, target) - publication = {"schema":"puber_publication_report_v1","operation_digest":attested["operation"].operation_digest,"target_commit":target,"release_tag":tag,"tag_status":tag_status,"notes":notes,"unresolved":False} - helper.emit("monitor_release", workspace_path=str(ROOT), operation_id=attested["operation"].operation_digest, release_version=version, release_tag=tag, target_commit=target, pr_url=pr_url, tag_push_status=tag_status, release_notes_path=notes["path"], publication_report=helper.report_json(publication), no_live_apply=False) - except Exception as error: - preserved = {key: payload[key] for key in payload if key != "_kent"} if isinstance(payload, dict) else {} - preserved.update({key: value for key, value in derived.items() if value}) - for key in ("workspace_path", "operation_id", "pr_url", "branch_name", "merge_strategy", "pr_head_oid", "pr_base_oid", "release_version", "release_tag", "target_commit", "release_notes_path", "publication_report"): - preserved.setdefault(key, "") - preserved["blocker_reason"] = str(error) - helper.emit("publish_needs_user_action", **preserved, no_live_apply=True) - return 0 -if __name__ == "__main__": raise SystemExit(main()) + base["target_commit"] = target + existing_report = None + if payload.get("publication_report"): + existing_report = helper.parse_publication_report(payload["publication_report"], payload.get("publication_report_digest"), expected_pr=pr_url, expected_version=version, expected_tag=tag, expected_target=target, task_short_id=state["task_short_id"], root=ROOT) + notes = existing_report["notes_report"] + else: + notes = helper.notes_report(ROOT, state["task_short_id"], tag, target) + if existing_report is None: + attested = helper.attest(ROOT, state, "publish", edge["key"], kind="publish", project_fields={"release_tag": tag, "target_commit": target, "release_version": version}) + operation_digest = attested["operation"].operation_digest + tag_status = helper.publish_tag(ROOT, tag, target) + report, report_digest = helper.publication_report(operation_digest, pr_url, version, tag, target, notes, tag_phase="tag_published", tag_status=tag_status, unresolved=True) + else: + helper.attest(ROOT, state, "publish", edge["key"], kind=None) + tag_status = helper.publish_tag(ROOT, tag, target) + report, report_digest = helper.publication_report(existing_report["operation_digest"], pr_url, version, tag, target, notes, tag_phase="tag_published", tag_status=tag_status, unresolved=True) + fields = { + **base, + "pr_url": pr_url, + "release_notes_path": notes["path"], + "publication_report": report, + "publication_report_digest": report_digest, + "release_run": helper.canonical_json(helper.NOT_SELECTED_RUN), + "tag_push_status": tag_status, + } + helper.emit_exact(ROOT, "monitor_release", "monitor", {key: fields[key] for key in ("release_version", "release_tag", "target_commit", "pr_url", "tag_push_status", "release_notes_path", "publication_report", "publication_report_digest", "release_run")}) + return 0 + except helper.ContractError as error: + version = values.get("release_version", "") + tag = values.get("release_tag", f"v{version}" if version else "") + fields = _base_fields(payload or {}, state, version, tag) + fields["blocker_reason"] = str(error) + helper.emit_exact(ROOT, "publish_needs_user_action", "publish", fields) + return 0 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.kent/scripts/workflow-release-cleanup b/.kent/scripts/workflow-release-cleanup index e8419bd4..6bfdafc9 100755 --- a/.kent/scripts/workflow-release-cleanup +++ b/.kent/scripts/workflow-release-cleanup @@ -1,25 +1,103 @@ #!/usr/bin/env python3 from __future__ import annotations -import importlib.util, json -from importlib.machinery import SourceFileLoader + +import importlib.util +import json import sys +from importlib.machinery import SourceFileLoader from pathlib import Path + sys.dont_write_bytecode = True _helper_path = Path(__file__).with_name("workflow-puber-release-intent") -spec = importlib.util.spec_from_loader("puber_release_runtime", SourceFileLoader("puber_release_runtime", str(_helper_path))); helper = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(helper) -ROOT=helper.ROOT +_spec = importlib.util.spec_from_loader("puber_release_runtime", SourceFileLoader("puber_release_runtime", str(_helper_path))) +helper = importlib.util.module_from_spec(_spec) +assert _spec and _spec.loader +_spec.loader.exec_module(helper) +ROOT = helper.ROOT + +def _eligible_session(state: dict) -> str: + rows = state["task"].get("live_sessions") + if not isinstance(rows, list): + raise helper.ContractError("Task has no live session rows") + candidates = [] + for row in rows: + if not isinstance(row, dict): + continue + session_id = row.get("session_id") or row.get("id") + if not isinstance(session_id, str) or not session_id: + continue + if row.get("task_id") not in {None, state["task_id"]} or row.get("worktree_path") not in {None, str(ROOT)}: + continue + if row.get("status") not in {None, "live", "retained", "active"}: + continue + if row.get("task_id") is None or row.get("worktree_path") is None: + continue + candidates.append(session_id) + if len(candidates) != 1: + raise helper.ContractError("no exact eligible retained/live Kent Session belongs to this task/worktree") + return candidates[0] + +def _cleanup_report(state: dict, session: str, payload: dict, *, unresolved: bool, blocker: str) -> tuple[str, str]: + publication = payload.get("publication_report", "") + release = payload.get("release_report", "") + notes_path = payload.get("release_notes_path", "") + value = {"schema": "puber_cleanup_report_v1", "operation_digest": payload.get("operation_id", ""), "cleanup_session_id": session, "cleanup_mode": "report_only", "publication_report": publication, "publication_report_digest": payload.get("publication_report_digest", ""), "release_report": release, "release_report_digest": payload.get("release_report_digest", ""), "release_notes_path": notes_path, "release_notes_digest": payload.get("release_notes_digest", ""), "unresolved": unresolved, "destructive_action": False, "blocker_reason": blocker} + encoded = helper.canonical_json(value) + return encoded, helper.sha256_bytes(encoded.encode("utf-8")) def main() -> int: - payload=None try: - payload,state,edge=helper.validate_envelope(helper.read_zero_argument(),ROOT,"cleanup") - attested=helper.attest(ROOT,state,"cleanup",edge["key"]) - sessions=state["task"].get("live_sessions"); ids=[item.get("session_id") or item.get("id") for item in sessions if isinstance(item,dict)] if isinstance(sessions,list) else [] - ids=[item for item in ids if isinstance(item,str) and item] - if len(ids)!=1: raise helper.ContractError("cleanup requires one exact live Kent session identity") - body=helper.declarations(state["task"].get("body")); report={"schema":"puber_cleanup_report_v1","operation_digest":attested["binding"].provenance_fingerprint,"publication_report":payload.get("publication_report",""),"release_report":payload.get("release_report",""),"release_notes_path":payload.get("release_notes_path",""),"unresolved":bool(payload.get("blocker_reason")),"destructive_action":False} - helper.emit("cleanup_task_janitor",workspace_path=str(ROOT),branch_name=body.get("release_branch",helper.git(ROOT,"branch","--show-current")),pr_url=payload.get("pr_url",""),cleanup_mode="report_only",cleanup_session_id=ids[0],task_short_id=state["task_short_id"],cleanup_report=helper.report_json(report),publication_report=payload.get("publication_report",""),release_report=payload.get("release_report",""),release_notes_path=payload.get("release_notes_path",""),no_live_apply=True) - except Exception as error: - helper.emit("cleanup_task_janitor",workspace_path=str(ROOT),branch_name=helper.git(ROOT,"branch","--show-current",check=False),pr_url=payload.get("pr_url","") if isinstance(payload,dict) else "",cleanup_mode="report_only",cleanup_session_id="",task_short_id=state["task_short_id"] if isinstance(locals().get("state"),dict) else "",cleanup_report=helper.report_json({"schema":"puber_cleanup_report_v1","unresolved":True,"destructive_action":False,"blocker_reason":str(error)}),no_live_apply=True) - return 0 -if __name__ == "__main__": raise SystemExit(main()) + raw = helper.read_zero_argument() + payload, state, edge = helper.validate_envelope(raw, ROOT, "cleanup") + except helper.ContractError as error: + print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr) + return 2 + prior_unresolved = False + prior_blocker = "" + try: + if edge["key"] == "task_janitor_blocked": + previous = helper.parse_closed_json(payload["cleanup_report"], "puber_cleanup_report_v1", helper.CLEANUP_KEYS) + prior_session = previous["cleanup_session_id"] + if not isinstance(prior_session, str) or not prior_session: + raise helper.ContractError("prior cleanup report has no exact Session") + session = _eligible_session(state) + if session != prior_session: + raise helper.ContractError("prior cleanup Session is not the exact current eligible Session") + if previous["publication_report"]: + helper.parse_publication_report(previous["publication_report"], previous["publication_report_digest"], task_short_id=state["task_short_id"], root=ROOT) + if previous["release_report"]: + helper.parse_release_report(previous["release_report"], previous["release_report_digest"]) + if previous["release_notes_path"]: + helper.validate_notes_path(ROOT, previous["release_notes_path"], task_short_id=state["task_short_id"]) + carrier = {**payload, "publication_report": previous["publication_report"], "publication_report_digest": previous["publication_report_digest"], "release_report": previous["release_report"], "release_report_digest": previous["release_report_digest"], "release_notes_path": previous["release_notes_path"], "release_notes_digest": previous["release_notes_digest"], "operation_id": previous["operation_digest"]} + prior_unresolved = previous["unresolved"] + prior_blocker = previous["blocker_reason"] + else: + helper.attest(ROOT, state, "cleanup", edge["key"]) + session = _eligible_session(state) + carrier = payload + if carrier.get("publication_report"): + helper.parse_publication_report(carrier["publication_report"], carrier["publication_report_digest"], task_short_id=state["task_short_id"], root=ROOT) + if carrier.get("release_report"): + helper.parse_release_report(carrier["release_report"], carrier["release_report_digest"]) + if carrier.get("release_notes_path"): + helper.validate_notes_path(ROOT, carrier["release_notes_path"], task_short_id=state["task_short_id"]) + declarations = helper.declarations(state["task"].get("body")) + version = declarations.get("release_version") + branch = carrier.get("branch_name") or declarations.get("release_branch", "") + if version: + helper.safe_release_branch(branch, version) + elif not helper.RELEASE_BRANCH.fullmatch(branch): + raise helper.ContractError("cleanup branch carrier is not safe") + carrier["branch_name"] = branch + blocker = carrier.get("blocker_reason", "") or prior_blocker + report, report_digest = _cleanup_report(state, session, carrier, unresolved=prior_unresolved or bool(blocker), blocker=blocker) + fields = {"cleanup_report": report, "workspace_path": str(ROOT), "branch_name": branch, "pr_url": carrier.get("pr_url", ""), "cleanup_mode": "report_only", "cleanup_session_id": session, "task_short_id": state["task_short_id"], "publication_report": carrier.get("publication_report", ""), "publication_report_digest": carrier.get("publication_report_digest", ""), "release_report": carrier.get("release_report", ""), "release_report_digest": carrier.get("release_report_digest", ""), "release_notes_path": carrier.get("release_notes_path", ""), "release_notes_digest": carrier.get("release_notes_digest", "")} + helper.emit_exact(ROOT, "cleanup_task_janitor", "task_janitor", fields) + return 0 + except helper.ContractError as error: + print(json.dumps({"error": str(error), "destructive_action": False}, ensure_ascii=False), file=sys.stderr) + return 2 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.kent/scripts/workflow-wait-github-release b/.kent/scripts/workflow-wait-github-release index 49834885..57e1bcf0 100755 --- a/.kent/scripts/workflow-wait-github-release +++ b/.kent/scripts/workflow-wait-github-release @@ -1,54 +1,75 @@ #!/usr/bin/env python3 from __future__ import annotations -import importlib.util, json -from importlib.machinery import SourceFileLoader + +import importlib.util +import json import sys +from importlib.machinery import SourceFileLoader from pathlib import Path + sys.dont_write_bytecode = True _helper_path = Path(__file__).with_name("workflow-puber-release-intent") -spec = importlib.util.spec_from_loader("puber_release_runtime", SourceFileLoader("puber_release_runtime", str(_helper_path))); helper = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(helper) +_spec = importlib.util.spec_from_loader("puber_release_runtime", SourceFileLoader("puber_release_runtime", str(_helper_path))) +helper = importlib.util.module_from_spec(_spec) +assert _spec and _spec.loader +_spec.loader.exec_module(helper) ROOT = helper.ROOT -def run_tuple(payload, tag, target): - existing = payload.get("release_run") - if existing is None: - result = helper.gh(ROOT, ["run","list","--workflow",".github/workflows/release.yml","--event","push","--json","databaseId,attempt,headSha,ref,event,workflowName,workflowPath"]) - if result.returncode != 0: raise helper.ContractError("release run discovery failed") - rows = json.loads(result.stdout or "[]") - matches = [row for row in rows if isinstance(row,dict) and row.get("event")=="push" and row.get("workflowName")=="Release" and row.get("workflowPath")==".github/workflows/release.yml" and row.get("headSha")==target and row.get("ref")==f"refs/tags/{tag}"] - if len(matches) != 1: raise helper.ContractError("release run is missing or ambiguous") - row = matches[0]; existing = {"repository":"rovkinmax/Puber","workflow_path":".github/workflows/release.yml","workflow_name":"Release","event":"push","run_id":row.get("databaseId"),"attempt":row.get("attempt"),"head_sha":row.get("headSha"),"ref":row.get("ref")} - if not isinstance(existing,dict) or set(existing)!={"repository","workflow_path","workflow_name","event","run_id","attempt","head_sha","ref"} or existing["repository"]!="rovkinmax/Puber" or existing["workflow_path"]!=".github/workflows/release.yml" or existing["workflow_name"]!="Release" or existing["event"]!="push" or existing["head_sha"]!=target or existing["ref"]!=f"refs/tags/{tag}" or not isinstance(existing["run_id"],int) or existing["run_id"]<=0 or not isinstance(existing["attempt"],int) or existing["attempt"]<=0: - raise helper.ContractError("release run identity is foreign, stale, or switched") - return existing +def _report_carriers(payload: dict, state: dict) -> tuple[dict, dict]: + version, tag = helper.safe_version_tag(payload["release_version"], payload["release_tag"]) + target = helper.require_string(payload["target_commit"], "target_commit") + if not helper.SHA1.fullmatch(target): + raise helper.ContractError("target_commit is not exact") + notes_path = helper.validate_notes_path(ROOT, payload["release_notes_path"], task_short_id=state["task_short_id"]) + publication = helper.parse_publication_report(payload["publication_report"], payload["publication_report_digest"], expected_pr=payload.get("pr_url") or None, expected_version=version, expected_tag=tag, expected_target=target, task_short_id=state["task_short_id"], root=ROOT) + if publication["notes_report"]["path"] != notes_path or publication["unresolved"] is not True: + raise helper.ContractError("publication report is not the unresolved canonical carrier") + run = helper.parse_run_carrier(payload["release_run"]) + if run != helper.NOT_SELECTED_RUN and (run["head_sha"] != target or run["head_branch"] != tag or run["ref"] != f"refs/tags/{tag}"): + raise helper.ContractError("release_run carrier is stale or switched") + return publication, run + +def _release_report(run: dict, jobs: dict, release: dict, publication: dict, notes_digest: str, checksum: dict) -> tuple[str, str]: + value = {"schema": "puber_release_report_v1", "run": run, "jobs": jobs, "release": release, "assets": release["assets"], "checksum": checksum, "notes_digest": notes_digest, "publication_report_digest": publication["digest"], "unresolved": False} + encoded = helper.canonical_json(value) + return encoded, helper.sha256_bytes(encoded.encode("utf-8")) def main() -> int: payload = None try: payload, state, edge = helper.validate_envelope(helper.read_zero_argument(), ROOT, "monitor") - tag = helper.require_string(payload["release_tag"], "release_tag"); target = helper.require_string(payload["target_commit"], "target_commit"); notes_path = helper.require_string(payload["release_notes_path"], "release_notes_path") - notes_bytes = (ROOT / notes_path).read_bytes(); notes_digest = helper.sha256_bytes(notes_bytes) - run = run_tuple(payload, tag, target) - attested = helper.attest(ROOT, state, "monitor", edge["key"], kind="observe_release", run_tuple=run, project_fields={"release_tag":tag}) - checked = helper.gh(ROOT, ["run","view",str(run["run_id"]),"--attempt",str(run["attempt"]),"--json","databaseId,attempt,workflowName,workflowPath,event,headSha,ref,status,conclusion,jobs"]) - if checked.returncode != 0: raise helper.ContractError("exact release run read failed") - run_state=json.loads(checked.stdout) - if run_state.get("databaseId")!=run["run_id"] or run_state.get("attempt")!=run["attempt"] or run_state.get("workflowPath")!=".github/workflows/release.yml" or run_state.get("headSha")!=target or run_state.get("status")!="completed" or run_state.get("conclusion")!="success": raise helper.ContractError("exact release run failed or switched") - jobs=run_state.get("jobs") - if not isinstance(jobs,list) or not jobs or any(isinstance(job,dict) and (job.get("status") not in {None,"completed"} or job.get("conclusion")!="success") for job in jobs): raise helper.ContractError("exact release jobs are not successful") - viewed=helper.gh(ROOT,["release","view",tag,"--json","tagName,targetCommitish,name,isDraft,isPrerelease,assets,body,url"]) - if viewed.returncode != 0: raise helper.ContractError("GitHub Release is missing") - release=json.loads(viewed.stdout); names={a.get("name") for a in release.get("assets",[]) if isinstance(a,dict)} - if release.get("tagName")!=tag or release.get("targetCommitish") not in {target,target[:7]} or release.get("name")!=f"Puber {tag}" or release.get("isDraft") or release.get("isPrerelease") or not any(str(name).endswith(".apk") for name in names) or not any(str(name).endswith(".sha256") for name in names): raise helper.ContractError("Release identity/assets/checksum are not exact") - expected=notes_bytes.decode("utf-8") - if release.get("body") != expected: - edited=helper.gh(ROOT,["release","edit",tag,"--notes-file","-"],input_text=expected) - if edited.returncode != 0: raise helper.ContractError("release notes edit failed") - reread=helper.gh(ROOT,["release","view",tag,"--json","tagName,targetCommitish,name,isDraft,isPrerelease,assets,body,url"]) - if reread.returncode != 0 or json.loads(reread.stdout).get("body") != expected: raise helper.ContractError("release notes body readback mismatch") - report={"schema":"puber_release_report_v1","run":run,"release":release,"notes_sha256":notes_digest,"publication_report":payload.get("publication_report",""),"unresolved":False} - helper.emit("release_release_published",release_report=helper.report_json(report),publication_report=payload.get("publication_report",""),release_notes_path=notes_path,release_run=json.dumps(run,sort_keys=True),no_live_apply=False) - except Exception as error: - preserved={key:payload[key] for key in payload if key!="_kent"} if isinstance(payload,dict) else {}; preserved["blocker_reason"]=str(error); helper.emit("monitor_needs_user_action",**preserved,no_live_apply=True) - return 0 -if __name__ == "__main__": raise SystemExit(main()) + except helper.ContractError as error: + print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr) + return 2 + try: + publication, run = _report_carriers(payload, state) + if run == helper.NOT_SELECTED_RUN: + run = helper.discover_release_run(ROOT, payload["release_tag"], payload["target_commit"]) + payload["release_run"] = helper.canonical_json(run) + run_for_api, jobs = helper.read_release_run(ROOT, run, payload["target_commit"], payload["release_tag"]) + release = helper.read_release(ROOT, payload["release_tag"], payload["target_commit"]) + notes = helper.parse_notes_report(publication["notes_report"], ROOT, state["task_short_id"], expected_tag=payload["release_tag"], expected_target=payload["target_commit"]) + notes_bytes = (ROOT / notes["path"]).read_bytes() + body = release.get("body", "") + if body != notes_bytes.decode("utf-8"): + edited = helper.gh(ROOT, ["release", "edit", payload["release_tag"], "--notes-file", "-"], input_text=notes_bytes.decode("utf-8")) + if edited.returncode: + raise helper.ContractError("release notes edit failed after all verification gates") + release = helper.read_release(ROOT, payload["release_tag"], payload["target_commit"]) + if release.get("body") != notes_bytes.decode("utf-8"): + raise helper.ContractError("release notes post-edit readback mismatch") + final_publication, final_publication_digest = helper.publication_report(publication["operation_digest"], publication["pr_url"], publication["release_version"], publication["release_tag"], publication["target_commit"], notes, tag_phase="tag_published", tag_status=publication["tag_status"], unresolved=False) + publication_final = helper.parse_publication_report(final_publication, final_publication_digest, expected_pr=publication["pr_url"], expected_version=publication["release_version"], expected_tag=publication["release_tag"], expected_target=publication["target_commit"], task_short_id=state["task_short_id"], root=ROOT) + publication_final["digest"] = final_publication_digest + report, report_digest = _release_report(run, {"run": run_for_api, "job": jobs}, release, publication_final, notes["sha256"], {"apk_sha256": helper.sha256_bytes(helper.gh_api_bytes(ROOT, f"repos/{helper.REPOSITORY}/releases/assets/{next(asset['id'] for asset in release['assets'] if asset['name'].endswith('.apk'))}")), "apk_name": next(asset["name"] for asset in release["assets"] if asset["name"].endswith(".apk")), "checksum_name": next(asset["name"] for asset in release["assets"] if asset["name"].endswith(".apk.sha256"))}) + helper.emit_exact(ROOT, "release_release_published", "cleanup", {"release_report": report, "publication_report": final_publication, "publication_report_digest": final_publication_digest, "release_notes_path": notes["path"], "release_notes_digest": notes["sha256"], "release_run": helper.canonical_json(run), "release_report_digest": report_digest}) + return 0 + except helper.ContractError as error: + preserved = dict(payload or {}) + preserved.pop("_kent", None) + preserved["blocker_reason"] = str(error) + helper.emit_exact(ROOT, "monitor_needs_user_action", "monitor", {key: preserved.get(key, helper.canonical_json(helper.NOT_SELECTED_RUN) if key == "release_run" else "") for key in ("blocker_reason", "workspace_path", "operation_id", "pr_url", "branch_name", "release_version", "release_tag", "target_commit", "tag_push_status", "release_notes_path", "publication_report", "publication_report_digest", "release_run")}) + return 0 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.kent/workflows/builders/puber_release.py b/.kent/workflows/builders/puber_release.py index c07c1f8e..fa42001b 100755 --- a/.kent/workflows/builders/puber_release.py +++ b/.kent/workflows/builders/puber_release.py @@ -42,6 +42,10 @@ } SCRIPT_EDGE_PARAMETERS = { "start_release_intent_gate": ("release_intent_gate", ()), + "release_intent_passed": ("prepare", ("workspace_path", "operation_id", "task_short_id", "release_type", "release_version")), + "release_intent_blocked": ("release_intent_gate", ("workspace_path", "operation_id", "task_short_id", "release_type", "release_version", "blocker_reason")), + "release_intent_needs_user_action": ("release_intent_gate", ("workspace_path", "operation_id", "task_short_id", "release_type", "release_version", "blocker_reason")), + "release_intent_invalid": ("release_intent_gate", ("workspace_path", "operation_id", "task_short_id", "release_type", "release_version", "blocker_reason")), "merge_watch_pr_merged": ( "publish", ( @@ -70,6 +74,8 @@ "target_commit", "release_notes_path", "publication_report", + "publication_report_digest", + "release_run", ), ), "monitor_release": ( @@ -82,6 +88,7 @@ "tag_push_status", "release_notes_path", "publication_report", + "publication_report_digest", "release_run", ), ), @@ -99,12 +106,13 @@ "tag_push_status", "release_notes_path", "publication_report", + "publication_report_digest", "release_run", ), ), "release_release_published": ( "cleanup", - ("release_report", "publication_report", "release_notes_path", "release_run"), + ("release_report", "publication_report", "publication_report_digest", "release_notes_path", "release_notes_digest", "release_run", "release_report_digest"), ), "cleanup_task_janitor": ( "task_janitor", @@ -117,10 +125,18 @@ "cleanup_session_id", "task_short_id", "publication_report", + "publication_report_digest", "release_report", + "release_report_digest", "release_notes_path", + "release_notes_digest", ), ), + "release_cancel_cleanup": ("cleanup", ("pr_url", "branch_name", "workspace_path", "cleanup_reason")), + "merge_watch_close_without_merge": ("cleanup", ("workspace_path", "operation_id", "pr_url", "branch_name", "merge_strategy", "pr_head_oid", "pr_base_oid")), + "task_janitor_blocked": ("cleanup", ("workspace_path", "operation_id", "branch_name", "cleanup_mode", "cleanup_report")), + "task_janitor_needs_user_action": ("task_janitor", ("workspace_path", "operation_id", "branch_name", "cleanup_mode", "cleanup_report")), + "task_janitor_retry": ("task_janitor", ("workspace_path", "operation_id", "branch_name", "cleanup_mode", "cleanup_report")), } EXPECTED_NODES = set(BASE_NODE_IDS) | set(NEW_NODES) EXPECTED_COMMANDS = { diff --git a/.kent/workflows/puber-release.json b/.kent/workflows/puber-release.json index e1fad4f4..a1d2bb03 100644 --- a/.kent/workflows/puber-release.json +++ b/.kent/workflows/puber-release.json @@ -859,6 +859,11 @@ "description": "Immutable S05 carrier field: publication_report.", "purpose": "ordinary" }, + { + "key": "publication_report_digest", + "description": "Digest of the closed publication report carrier.", + "purpose": "ordinary" + }, { "key": "release_run", "description": "Immutable S05 carrier field: release_run.", @@ -945,6 +950,16 @@ "key": "publication_report", "description": "Immutable S05 carrier field: publication_report.", "purpose": "ordinary" + }, + { + "key": "publication_report_digest", + "description": "Digest of the closed publication report carrier.", + "purpose": "ordinary" + }, + { + "key": "release_run", + "description": "Pinned release run carrier, or explicit not-selected state.", + "purpose": "ordinary" } ] }, @@ -973,15 +988,30 @@ "description": "Immutable S05 carrier field: publication_report.", "purpose": "ordinary" }, + { + "key": "publication_report_digest", + "description": "Digest of the closed publication report carrier.", + "purpose": "ordinary" + }, { "key": "release_notes_path", "description": "Immutable S05 carrier field: release_notes_path.", "purpose": "ordinary" }, + { + "key": "release_notes_digest", + "description": "Digest of canonical notes bytes.", + "purpose": "ordinary" + }, { "key": "release_run", "description": "Immutable S05 carrier field: release_run.", "purpose": "ordinary" + }, + { + "key": "release_report_digest", + "description": "Digest of the closed release report carrier.", + "purpose": "ordinary" } ] }, @@ -1055,6 +1085,11 @@ "description": "Immutable S05 carrier field: publication_report.", "purpose": "ordinary" }, + { + "key": "publication_report_digest", + "description": "Digest of the closed publication report carrier.", + "purpose": "ordinary" + }, { "key": "release_run", "description": "Immutable S05 carrier field: release_run.", @@ -1116,15 +1151,30 @@ "description": "Immutable S05 carrier field: publication_report.", "purpose": "ordinary" }, + { + "key": "publication_report_digest", + "description": "Digest of the closed publication report carrier.", + "purpose": "ordinary" + }, { "key": "release_report", "description": "Immutable S05 carrier field: release_report.", "purpose": "ordinary" }, + { + "key": "release_report_digest", + "description": "Digest of the closed release report carrier.", + "purpose": "ordinary" + }, { "key": "release_notes_path", "description": "Immutable S05 carrier field: release_notes_path.", "purpose": "ordinary" + }, + { + "key": "release_notes_digest", + "description": "Digest of canonical notes bytes.", + "purpose": "ordinary" } ] }, @@ -1433,6 +1483,11 @@ "key": "release_version", "description": "Resolved release version.", "purpose": "ordinary" + }, + { + "key": "blocker_reason", + "description": "Blocker reason.", + "purpose": "ordinary" } ] }, @@ -1469,6 +1524,11 @@ "key": "release_version", "description": "Resolved release version.", "purpose": "ordinary" + }, + { + "key": "blocker_reason", + "description": "Blocker reason.", + "purpose": "ordinary" } ] }, @@ -1505,6 +1565,11 @@ "key": "release_version", "description": "Resolved release version.", "purpose": "ordinary" + }, + { + "key": "blocker_reason", + "description": "Blocker reason.", + "purpose": "ordinary" } ] }, diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json index 7e9b5486..2a414125 100644 --- a/.kent/workflows/puber-release.manifest.json +++ b/.kent/workflows/puber-release.manifest.json @@ -26,7 +26,7 @@ "external_roots": [ { "kind": "builder-sha256", - "key": "5a63b67ad94f7e80ee8d2997e36ab44889d2b8692daaaae7b1897cd985fe79e1", + "key": "69459aa3af3bd81cc4bd5bd4ea0023fee60d7d0c45565815827eb146cbfcb240", "runtime_digest_required": true }, { @@ -51,7 +51,7 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-puber-release-graph=7b08a2b4777979dd7895b3e0018dc5921f379ab36da5125f9c640b97ce383b32", + "key": ".kent/scripts/tests/test-puber-release-graph=3aef07745548fe8bdf4c2e6735259179591085c7152f594438da2b9a5764b61e", "runtime_digest_required": true }, { @@ -61,17 +61,17 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-puber-release-intent=810af065003aa5209bd8d38d63dbffb76de37006ed86f88c13e8d0d1a0984a40", + "key": ".kent/scripts/tests/test-workflow-puber-release-intent=1b4c09101d70a41da0e5bdd2b39edbf78569250ca6c3f3e4ec3e77c00fb43551", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-puber-release-publish=6d44873cb2e2761745bfe68f16415057e0e866188098643b92b142196f845464", + "key": ".kent/scripts/tests/test-workflow-puber-release-publish=456ce670f0956625ac86301c4b9c43753b7a274016aee329e28e2add351161f1", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-release-cleanup=d55a25a58a1b1bae1a2fac381b04c4a80c57d4feae14fd70b018763837cac732", + "key": ".kent/scripts/tests/test-workflow-release-cleanup=33e2187493e16017c81b0c03e06c93e8aefb98378777e4c93629b4c3e146e5c7", "runtime_digest_required": true }, { @@ -81,22 +81,22 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-wait-github-release=cf994049ba2a83d664e8df3e59bca5ff6bede8afb997723f92886e6029d857a9", + "key": ".kent/scripts/tests/test-workflow-wait-github-release=5edf0a424a14a6c059a56b9a97d37898d8907e290901ed41577884c106115006", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-puber-release-intent=e3583c188e626d1c44fd1f06b805d95e1bf35a14f72a865e518b5372d5c314d8", + "key": ".kent/scripts/workflow-puber-release-intent=690b487b178cb17b826ccce1b401c6e00b0c1d46a3e498f006f31438df857263", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-release-cleanup=29a3e45451bcfdd0ca0ba9397fab5e4d3b0f31ff969768c0f0ef3681236557f2", + "key": ".kent/scripts/workflow-release-cleanup=d717a86124ee3dd06c7cd3de96ed4283d0643fb80afe6643da99ef239367dff4", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-wait-github-release=033f888d1ac244af28a0f3b0ae40de136757478ca27507c16cd999abc118f2b1", + "key": ".kent/scripts/workflow-wait-github-release=e39e54cc217723ad3f8f65129a65a25547d1e57934f41d8701f714194d6388a2", "runtime_digest_required": true }, { diff --git a/.kent/workflows/specs/puber-release.toml b/.kent/workflows/specs/puber-release.toml index 02d7d4d6..b805505b 100644 --- a/.kent/workflows/specs/puber-release.toml +++ b/.kent/workflows/specs/puber-release.toml @@ -63,7 +63,7 @@ types = [] dispatch_inputs = [] [[required_jobs_v1.jobs.steps]] kind = "uses" -name = "Checkout exact source" +name = "" condition = "" continue_on_error = false uses = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" @@ -77,7 +77,7 @@ validation_required = true "persist-credentials" = false [[required_jobs_v1.jobs.steps]] kind = "uses" -name = "Set up Java 21" +name = "" condition = "" continue_on_error = false uses = "actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00" @@ -105,7 +105,7 @@ secret_refs = [] validation_required = true [[required_jobs_v1.jobs.steps]] kind = "run" -name = "Run Detekt" +name = "" condition = "" continue_on_error = false uses = "" @@ -155,7 +155,7 @@ types = [] dispatch_inputs = [] [[required_jobs_v1.jobs.steps]] kind = "uses" -name = "Checkout exact source" +name = "" condition = "" continue_on_error = false uses = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" @@ -169,7 +169,7 @@ validation_required = true "persist-credentials" = false [[required_jobs_v1.jobs.steps]] kind = "uses" -name = "Set up Java 21" +name = "" condition = "" continue_on_error = false uses = "actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00" @@ -197,7 +197,7 @@ secret_refs = [] validation_required = true [[required_jobs_v1.jobs.steps]] kind = "run" -name = "Run unit tests" +name = "" condition = "" continue_on_error = false uses = "" @@ -247,7 +247,7 @@ types = [] dispatch_inputs = [] [[required_jobs_v1.jobs.steps]] kind = "uses" -name = "Checkout exact source" +name = "" condition = "" continue_on_error = false uses = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" @@ -261,7 +261,7 @@ validation_required = true "persist-credentials" = false [[required_jobs_v1.jobs.steps]] kind = "uses" -name = "Set up Java 21" +name = "" condition = "" continue_on_error = false uses = "actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00" @@ -289,7 +289,7 @@ secret_refs = [] validation_required = true [[required_jobs_v1.jobs.steps]] kind = "run" -name = "Build debug APK" +name = "" condition = "" continue_on_error = false uses = "" @@ -346,7 +346,7 @@ types = [] dispatch_inputs = [] [[effect_jobs_v1.jobs.steps]] kind = "uses" -name = "Checkout exact tagged source" +name = "" condition = "" continue_on_error = false uses = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" @@ -361,7 +361,7 @@ validation_required = false "ref" = "${{ github.ref }}" [[effect_jobs_v1.jobs.steps]] kind = "uses" -name = "Set up Java 21" +name = "" condition = "" continue_on_error = false uses = "actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00" @@ -376,33 +376,46 @@ validation_required = false "distribution" = "temurin" [[effect_jobs_v1.jobs.steps]] kind = "run" -name = "Build release APK" +name = "Assert GitHub-hosted runner" condition = "" continue_on_error = false uses = "" +run = "test \"${RUNNER_ENVIRONMENT:-github-hosted}\" = github-hosted" +effective_shell = "" +effective_working_directory = "" +effective_environment = { } +secret_refs = [] +validation_required = false with = { } +[[effect_jobs_v1.jobs.steps]] +kind = "run" +name = "" +condition = "" +continue_on_error = false +uses = "" run = "./gradlew :app:assembleProdRelease" effective_shell = "" effective_working_directory = "" effective_environment = { } secret_refs = [] validation_required = false +with = { } [[effect_jobs_v1.jobs.steps]] kind = "run" -name = "Prepare APK checksum" +name = "Prepare release artifacts and checksum" condition = "" continue_on_error = false uses = "" -with = { } -run = "sha256sum app/build/outputs/apk/prod/release/*.apk > release.sha256" +run = "mkdir -p release-artifacts\napk_path=\"$(find app/build/outputs/apk/prod/release -maxdepth 1 -type f -name '*.apk' | head -n 1)\"\ntest -n \"$apk_path\"\ncp \"$apk_path\" \"release-artifacts/puber-${GITHUB_REF_NAME}.apk\"\n(cd release-artifacts && sha256sum \"puber-${GITHUB_REF_NAME}.apk\" > \"puber-${GITHUB_REF_NAME}.apk.sha256\")\n" effective_shell = "" effective_working_directory = "" effective_environment = { } secret_refs = [] validation_required = false +with = { } [[effect_jobs_v1.jobs.steps]] kind = "uses" -name = "Upload APK" +name = "" condition = "" continue_on_error = false uses = "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02" @@ -414,20 +427,21 @@ secret_refs = [] validation_required = false [effect_jobs_v1.jobs.steps.with] "name" = "puber-${{ github.ref_name }}" -"path" = "app/build/outputs/apk/prod/release/*.apk\nrelease.sha256" +"path" = "release-artifacts/*" +"if-no-files-found" = "error" [[effect_jobs_v1.jobs.steps]] kind = "run" -name = "Create empty GitHub Release" +name = "Create GitHub Release with empty initial body" condition = "" continue_on_error = false uses = "" -with = { } -run = "gh release create \"$GITHUB_REF_NAME\" --verify-tag --title \"Puber $GITHUB_REF_NAME\" --notes \"\" --generate-notes=false release.sha256 app/build/outputs/apk/prod/release/*.apk" +run = "gh release create \"$GITHUB_REF_NAME\" --verify-tag --title \"Puber $GITHUB_REF_NAME\" --notes \"\" --generate-notes=false release-artifacts/*" effective_shell = "" effective_working_directory = "" effective_environment = { "GH_TOKEN" = "${{ github.token }}" } secret_refs = [] validation_required = false +with = { } [[operation_variants]] key = "publish_after_merge" From 88a5620e4168d23ab7f921abdc8e13aa7506bed9 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 06:13:06 +0500 Subject: [PATCH 09/21] test: keep release fixture commit idempotent --- .kent/scripts/tests/test-workflow-puber-release-publish | 3 ++- .kent/workflows/puber-release.manifest.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.kent/scripts/tests/test-workflow-puber-release-publish b/.kent/scripts/tests/test-workflow-puber-release-publish index 3cc1c05a..5ec4c447 100755 --- a/.kent/scripts/tests/test-workflow-puber-release-publish +++ b/.kent/scripts/tests/test-workflow-puber-release-publish @@ -36,7 +36,8 @@ def materialize(root: Path, bare: Path) -> tuple[Path, str, str]: sh(["git", "config", "user.email", "test@example.invalid"], work) sh(["git", "config", "user.name", "Puber Test"], work) sh(["git", "add", "."], work) - sh(["git", "commit", "--quiet", "-m", "test fixture"], work) + if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=work).returncode != 0: + sh(["git", "commit", "--quiet", "-m", "test fixture"], work) sh(["git", "init", "--bare", str(bare)], root) sh(["git", "remote", "set-url", "origin", str(bare)], work) sh(["git", "push", "--quiet", "-u", "origin", "HEAD:master"], work) diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json index 2a414125..5f4fe5f2 100644 --- a/.kent/workflows/puber-release.manifest.json +++ b/.kent/workflows/puber-release.manifest.json @@ -66,7 +66,7 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-puber-release-publish=456ce670f0956625ac86301c4b9c43753b7a274016aee329e28e2add351161f1", + "key": ".kent/scripts/tests/test-workflow-puber-release-publish=f7cfa26c071638037bf5cc1c5ea6c77b2681a991721d5dffebcc3b9578f08a3a", "runtime_digest_required": true }, { From 315a6ba28ef34979c00864dd0bcd6e647cf3fec0 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 06:37:18 +0500 Subject: [PATCH 10/21] fix: attest release observation and cleanup authority --- .../tests/test-workflow-puber-release-intent | 56 ++++++ .../tests/test-workflow-puber-release-publish | 53 +++++ .../tests/test-workflow-release-cleanup | 179 +++++++++++++++-- .../tests/test-workflow-wait-github-release | 11 +- .kent/scripts/workflow-puber-release-intent | 187 ++++++++++++++++-- .kent/scripts/workflow-puber-release-publish | 8 +- .kent/scripts/workflow-release-cleanup | 130 ++++++++---- .kent/scripts/workflow-wait-github-release | 32 ++- .kent/workflows/puber-release.manifest.json | 14 +- .kent/workflows/specs/puber-release.toml | 2 +- 10 files changed, 582 insertions(+), 90 deletions(-) diff --git a/.kent/scripts/tests/test-workflow-puber-release-intent b/.kent/scripts/tests/test-workflow-puber-release-intent index 8dfc9541..422d6085 100755 --- a/.kent/scripts/tests/test-workflow-puber-release-intent +++ b/.kent/scripts/tests/test-workflow-puber-release-intent @@ -7,6 +7,8 @@ import os from importlib.machinery import SourceFileLoader from pathlib import Path import subprocess +import tempfile +import shutil ROOT = Path(__file__).resolve().parents[3] KIT = Path("/Users/rovkinmax/.kent/worktrees/kent-engineering-kit/s02a-kit-profile") @@ -41,4 +43,58 @@ sources = mod._normalized_sources(ROOT, kit, release_spec) for key in ("publish_after_merge", "observe_release"): variant = next(item for item in release_spec.operation_variants if item.key == key) kit["validate_operation_jobs"](variant, sources, required=release_spec.required_jobs_v1, qualification=release_spec.qualification_jobs_v1, effect=release_spec.effect_jobs_v1) + +with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + work = root / "work" + subprocess.run(["git", "clone", "--quiet", str(ROOT), str(work)], cwd=root, check=True) + manifest = json.loads((ROOT / ".kent/workflows/puber-release.manifest.json").read_text(encoding="utf-8")) + for relative in sorted(set(manifest["additional_paths"]) | { + ".kent/scripts/workflow-puber-release-intent", + ".kent/workflows/puber-release.json", + ".kent/workflows/puber-release.manifest.json", + ".kent/workflows/specs/puber-release.toml", + ".kent/workflows/builders/puber_release.py", + }): + source = ROOT / relative + target = work / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + subprocess.run(["git", "config", "user.email", "test@example.invalid"], cwd=work, check=True) + subprocess.run(["git", "config", "user.name", "Puber Test"], cwd=work, check=True) + subprocess.run(["git", "add", "."], cwd=work, check=True) + if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=work).returncode != 0: + subprocess.run(["git", "commit", "--quiet", "-m", "intent test fixture"], cwd=work, check=True) + target_commit = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=work, text=True).strip() + graph = json.loads((work / ".kent/workflows/puber-release.json").read_text(encoding="utf-8")) + node = next(item for item in graph["nodes"] if item["key"] == "release_intent_gate") + task_id = "task-12345678-1234-4234-8234-123456789abc" + task = { + "actions": [], "attention_count": 0, + "body": "release_type: minor\nrelease_version: 99.0.0\nrelease_tag: v99.0.0\nrelease_branch: release/99.0.0\n", + "current_nodes": [{"node_id": node["id"]}], + "current_scripts": [{"path": ".kent/scripts/workflow-puber-release-intent", "current_node": {"node_id": node["id"]}}], + "execution_target": {"commit_oid": target_commit}, "label_ids": [], "live_sessions": [], + "project": {}, "retained_session_count": 0, "source_workspace": {}, + "status": {"node_ids": [node["id"]]}, + "summary": {"id": task_id, "project_id": "project-6c1b6c4e-c8e2-4170-b9f7-a7d9c4602176", "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "short_id": "PUB-99"}, + "workflow": {"workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "version": 88, "name": "Puber Release"}, + "worktree_path": str(work), + } + fake = root / "fake"; fake.mkdir() + task_file = fake / "task.json"; task_file.write_text(json.dumps(task), encoding="utf-8") + kent = fake / "kent"; kent.write_text("#!/bin/sh\ncat \"$PUBER_FAKE_TASK\"\n", encoding="utf-8"); kent.chmod(0o755) + env = {**os.environ, "KENT_ENGINEERING_KIT_ROOT": str(KIT), "PUBER_KENT_BIN": str(kent), "PUBER_FAKE_TASK": str(task_file)} + success = {"_kent": {"task_id": task_id, "node_id": node["id"], "transition_branch_key": "start_release_intent_gate"}} + result = subprocess.run([str(work / ".kent/scripts/workflow-puber-release-intent")], cwd=work, input=json.dumps(success), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert result.returncode == 0, result.stderr + output = json.loads(result.stdout) + assert output["transition"] == "release_intent_passed" and output["release_version"] == "99.0.0" + for bad in ( + {}, + {"_kent": success["_kent"], "extra": "field"}, + {"_kent": {**success["_kent"], "node_id": "wrong"}}, + ): + result = subprocess.run([str(work / ".kent/scripts/workflow-puber-release-intent")], cwd=work, input=json.dumps(bad), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert result.returncode != 0 and result.stdout == "" print("workflow-puber-release-intent: passed") diff --git a/.kent/scripts/tests/test-workflow-puber-release-publish b/.kent/scripts/tests/test-workflow-puber-release-publish index 5ec4c447..a466decf 100755 --- a/.kent/scripts/tests/test-workflow-puber-release-publish +++ b/.kent/scripts/tests/test-workflow-puber-release-publish @@ -2,7 +2,9 @@ from __future__ import annotations import json +import importlib.util import os +from importlib.machinery import SourceFileLoader from pathlib import Path import shutil import subprocess @@ -10,6 +12,8 @@ import tempfile ROOT = Path(__file__).resolve().parents[3] KIT = Path("/Users/rovkinmax/.kent/worktrees/kent-engineering-kit/s02a-kit-profile") +helper_spec = importlib.util.spec_from_loader("publish_runtime_test", SourceFileLoader("publish_runtime_test", str(ROOT / ".kent/scripts/workflow-puber-release-intent"))) +helper = importlib.util.module_from_spec(helper_spec); assert helper_spec and helper_spec.loader; helper_spec.loader.exec_module(helper) SCRIPT_PATHS = [ ".kent/scripts/workflow-puber-release-intent", ".kent/scripts/workflow-puber-release-publish", @@ -101,4 +105,53 @@ with tempfile.TemporaryDirectory() as directory: assert sh(["git", "--git-dir", bare_path, "rev-parse", "refs/tags/v99.0.0"], work).stdout.strip() == target assert not (ROOT / ".git/refs/tags/v99.0.0").exists() assert "api" in log.read_text(encoding="utf-8") and "git/ref/heads/master" in log.read_text(encoding="utf-8") + retry = { + "_kent": {"task_id": task_id, "node_id": publish_node["id"], "transition_branch_key": "publish_needs_user_action"}, + "blocker_reason": "retry", + "workspace_path": payload["workspace_path"], + "operation_id": payload["operation_id"], + "pr_url": payload["pr_url"], + "branch_name": payload["branch_name"], + "merge_strategy": payload["merge_strategy"], + "pr_head_oid": payload["pr_head_oid"], + "pr_base_oid": payload["pr_base_oid"], + "release_version": output["release_version"], + "release_tag": output["release_tag"], + "target_commit": output["target_commit"], + "release_notes_path": output["release_notes_path"], + "publication_report": output["publication_report"], + "publication_report_digest": output["publication_report_digest"], + "release_run": output["release_run"], + } + retry_result = subprocess.run([str(work / ".kent/scripts/workflow-puber-release-publish")], cwd=work, input=json.dumps(retry), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert retry_result.returncode == 0, retry_result.stderr + retry_output = json.loads(retry_result.stdout) + assert retry_output["transition"] == "monitor_release" + retry_report = json.loads(retry_output["publication_report"]) + assert retry_report["operation_digest"] != report["operation_digest"] + assert retry_report["previous_operation_digest"] == report["operation_digest"] + assert retry_output["tag_push_status"] == "already_present_exact" + wrong = sh(["git", "rev-parse", f"{target}^"], work).stdout.strip() + sh(["git", "tag", "v100.0.0", wrong], work) + try: + helper.publish_tag(work, "v100.0.0", target) + except helper.ContractError: + pass + else: + raise AssertionError("local conflicting tag was accepted") + sh(["git", "update-ref", "refs/tags/v100.0.1", wrong], Path(bare_path)) + try: + helper.publish_tag(work, "v100.0.1", target) + except helper.ContractError: + pass + else: + raise AssertionError("remote conflicting tag was accepted") + notes_path = work / report["notes_report"]["path"] + notes_path.write_text("conflict\n", encoding="utf-8") + try: + helper.parse_notes_report(report["notes_report"], work, "PUB-99", expected_tag="v99.0.0", expected_target=target) + except helper.ContractError: + pass + else: + raise AssertionError("conflicting canonical notes were accepted") print("workflow-puber-release-publish: passed") diff --git a/.kent/scripts/tests/test-workflow-release-cleanup b/.kent/scripts/tests/test-workflow-release-cleanup index 57914f92..a0412823 100755 --- a/.kent/scripts/tests/test-workflow-release-cleanup +++ b/.kent/scripts/tests/test-workflow-release-cleanup @@ -2,26 +2,175 @@ from __future__ import annotations import importlib.util +import json from importlib.machinery import SourceFileLoader +import os from pathlib import Path +import shutil +import subprocess import tempfile ROOT = Path(__file__).resolve().parents[3] -spec = importlib.util.spec_from_loader("cleanup_runtime_test", SourceFileLoader("cleanup_runtime_test", str(ROOT / ".kent/scripts/workflow-release-cleanup"))) -mod = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(mod) -state = {"task_id":"task-12345678-1234-4234-8234-123456789abc","task":{"live_sessions":[{"session_id":"session-1","task_id":"task-12345678-1234-4234-8234-123456789abc","worktree_path":str(ROOT),"status":"live"}]}} -assert mod._eligible_session(state) == "session-1" -state["task"]["live_sessions"] = [{"session_id":"session-1","task_id":"other","worktree_path":str(ROOT),"status":"live"}] -try: - mod._eligible_session(state) -except mod.helper.ContractError: - pass -else: - raise AssertionError("foreign cleanup Session accepted") +KIT = Path("/Users/rovkinmax/.kent/worktrees/kent-engineering-kit/s02a-kit-profile") + +spec = importlib.util.spec_from_loader( + "cleanup_runtime_test", + SourceFileLoader("cleanup_runtime_test", str(ROOT / ".kent/scripts/workflow-release-cleanup")), +) +mod = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(mod) + +def sh(args: list[str], cwd: Path, **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, cwd=cwd, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs) + with tempfile.TemporaryDirectory() as directory: root = Path(directory) - report, digest = mod._cleanup_report(state, "session-1", {"operation_id":"a"*64}, unresolved=True, blocker="not authorized") - parsed = mod.helper.parse_closed_json(report, "puber_cleanup_report_v1", mod.helper.CLEANUP_KEYS) - assert parsed["destructive_action"] is False and digest == mod.helper.sha256_bytes(mod.helper.canonical_json(parsed).encode()) - assert parsed["unresolved"] is True and parsed["blocker_reason"] == "not authorized" + work = root / "work" + sh(["git", "clone", "--quiet", str(ROOT), str(work)], root) + manifest = json.loads((ROOT / ".kent/workflows/puber-release.manifest.json").read_text(encoding="utf-8")) + for relative in sorted(set(manifest["additional_paths"]) | { + ".kent/scripts/workflow-puber-release-intent", + ".kent/scripts/workflow-release-cleanup", + ".kent/workflows/puber-release.json", + ".kent/workflows/puber-release.manifest.json", + ".kent/workflows/specs/puber-release.toml", + ".kent/workflows/builders/puber_release.py", + }): + source = ROOT / relative + target_path = work / relative + target_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target_path) + sh(["git", "config", "user.email", "test@example.invalid"], work) + sh(["git", "config", "user.name", "Puber Test"], work) + sh(["git", "add", "."], work) + if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=work).returncode != 0: + sh(["git", "commit", "--quiet", "-m", "cleanup test fixture"], work) + target = sh(["git", "rev-parse", "HEAD"], work).stdout.strip() + graph = json.loads((work / ".kent/workflows/puber-release.json").read_text(encoding="utf-8")) + cleanup_node = next(node for node in graph["nodes"] if node["key"] == "cleanup") + task_id = "task-12345678-1234-4234-8234-123456789abc" + session_id = "session-cleanup-1" + task = { + "actions": [], + "attention_count": 0, + "body": "release_version: 99.0.0\nrelease_tag: v99.0.0\nrelease_branch: release/99.0.0\n", + "current_nodes": [{"node_id": cleanup_node["id"]}], + "current_scripts": [{"path": ".kent/scripts/workflow-release-cleanup", "current_node": {"node_id": cleanup_node["id"]}}], + "execution_target": {"commit_oid": target}, + "label_ids": [], + "live_sessions": [{"session_id": session_id, "session_name": "Cleanup", "node_display_name": "cleanup"}], + "project": {}, + "retained_session_count": 0, + "source_workspace": {}, + "status": {"node_ids": [cleanup_node["id"]]}, + "summary": {"id": task_id, "project_id": "project-6c1b6c4e-c8e2-4170-b9f7-a7d9c4602176", "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "short_id": "PUB-99"}, + "workflow": {"workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "version": 88, "name": "Puber Release"}, + "worktree_path": str(work), + } + fake = root / "fake" + fake.mkdir() + task_file = fake / "task.json" + task_file.write_text(json.dumps(task), encoding="utf-8") + kent = fake / "kent" + kent.write_text("#!/bin/sh\ncat \"$PUBER_FAKE_TASK\"\n", encoding="utf-8") + kent.chmod(0o755) + wrapper = fake / "kent-worktree" + status_payload = json.dumps( + { + "target": {"EffectiveWorkdir": str(work)}, + "worktree": {"recorded_root": str(work), "observed_root": str(work)}, + }, + separators=(",", ":"), + ) + wrapper.write_text( + "#!/bin/sh\n" + "test \"$1\" = status && test \"$2\" = --session && test \"$4\" = --json\n" + f"printf '%s\\n' '{status_payload}'\n", + encoding="utf-8", + ) + wrapper.chmod(0o755) + env = { + **os.environ, + "KENT_ENGINEERING_KIT_ROOT": str(KIT), + "PUBER_KENT_BIN": str(kent), + "PUBER_KENT_WORKTREE_BIN": str(wrapper), + "PUBER_FAKE_TASK": str(task_file), + } + cancel = { + "_kent": {"task_id": task_id, "node_id": cleanup_node["id"], "transition_branch_key": "release_cancel_cleanup"}, + "pr_url": "", + "branch_name": "release/99.0.0", + "workspace_path": str(work), + "cleanup_reason": "cancelled", + } + result = subprocess.run( + [str(work / ".kent/scripts/workflow-release-cleanup")], + cwd=work, + input=json.dumps(cancel), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + assert result.returncode == 0, result.stderr + output = json.loads(result.stdout) + assert output["transition"] == "cleanup_task_janitor" + assert output["cleanup_session_id"] == session_id + report = json.loads(output["cleanup_report"]) + assert report["operation_digest"] != "" + assert report["destructive_action"] is False and report["unresolved"] is False + + task["live_sessions"] = [] + task_file.write_text(json.dumps(task), encoding="utf-8") + previous, _ = mod._cleanup_report( + {"task_id": task_id}, + session_id, + {"operation_id": "a" * 64}, + operation_digest="a" * 64, + unresolved=False, + blocker="", + ) + blocked = { + "_kent": {"task_id": task_id, "node_id": cleanup_node["id"], "transition_branch_key": "task_janitor_blocked"}, + "workspace_path": str(work), + "operation_id": "a" * 64, + "branch_name": "release/99.0.0", + "cleanup_mode": "report_only", + "cleanup_report": previous + "\nJanitor preserved the worktree for retry.", + } + result = subprocess.run( + [str(work / ".kent/scripts/workflow-release-cleanup")], + cwd=work, + input=json.dumps(blocked), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + assert result.returncode == 0, result.stderr + retry = json.loads(result.stdout) + retry_report = json.loads(retry["cleanup_report"]) + assert retry["transition"] == "cleanup_task_janitor" + assert retry["cleanup_session_id"] == session_id + assert retry_report["unresolved"] is True + assert retry_report["blocker_reason"].startswith("Janitor ") + assert retry_report["operation_digest"] != "a" * 64 + + invalid = dict(cancel) + invalid["cleanup_reason"] = "" + invalid["branch_name"] = "not-a-release-branch" + result = subprocess.run( + [str(work / ".kent/scripts/workflow-release-cleanup")], + cwd=work, + input=json.dumps(invalid), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + assert result.returncode != 0 + assert result.stdout == "" + assert "error" in result.stderr + print("workflow-release-cleanup: passed") diff --git a/.kent/scripts/tests/test-workflow-wait-github-release b/.kent/scripts/tests/test-workflow-wait-github-release index 873e9e3c..cff50d43 100755 --- a/.kent/scripts/tests/test-workflow-wait-github-release +++ b/.kent/scripts/tests/test-workflow-wait-github-release @@ -62,7 +62,7 @@ if "/releases/assets/2" in endpoint: sys.stdout.buffer.write(open(os.environ["PUBER_CHECKSUM"],"rb").read()); sys.exit(0) if "/releases/tags/" in endpoint: apk=open(os.environ["PUBER_APK"],"rb").read(); checksum=open(os.environ["PUBER_CHECKSUM"],"rb").read() - print(json.dumps({"tag_name":tag,"target_commitish":target,"name":"Puber "+tag,"draft":False,"prerelease":False,"html_url":"https://github.com/rovkinmax/Puber/releases/tag/"+tag,"body":open(os.environ["PUBER_BODY"],encoding="utf-8").read(),"assets":[{"id":1,"name":"puber-"+tag+".apk","size":len(apk),"browser_download_url":"https://example.invalid/apk"},{"id":2,"name":"puber-"+tag+".apk.sha256","size":len(checksum),"browser_download_url":"https://example.invalid/checksum"}]})); sys.exit(0) + print(json.dumps({"tag_name":tag,"target_commitish":"master","name":"Puber "+tag,"draft":False,"prerelease":False,"html_url":"https://github.com/rovkinmax/Puber/releases/tag/"+tag,"body":open(os.environ["PUBER_BODY"],encoding="utf-8").read(),"assets":[{"id":1,"name":"puber-"+tag+".apk","size":len(apk),"browser_download_url":"https://example.invalid/apk"},{"id":2,"name":"puber-"+tag+".apk.sha256","size":len(checksum),"browser_download_url":"https://example.invalid/checksum"}]})); sys.exit(0) sys.exit(3) """, encoding="utf-8", @@ -79,6 +79,13 @@ sys.exit(3) output = json.loads(result.stdout) assert output["transition"] == "release_release_published" assert json.loads(output["publication_report"])["unresolved"] is False - assert json.loads(output["release_report"])["unresolved"] is False + release_report = json.loads(output["release_report"]) + assert release_report["unresolved"] is False + assert release_report["observation_operation_digest"] and release_report["observation_provenance_fingerprint"] + assert release_report["release"]["target_commitish"] == "master" + assert release_report["release"]["remote_tag_target"] == target + assert release_report["checksum"]["apk_asset_id"] == 1 + assert release_report["checksum"]["checksum_asset_id"] == 2 + assert release_report["checksum"]["apk_checksum_equal"] is True assert body_file.read_text(encoding="utf-8").startswith("puber_release_notes_v1") print("workflow-wait-github-release: passed") diff --git a/.kent/scripts/workflow-puber-release-intent b/.kent/scripts/workflow-puber-release-intent index 984ead4f..86087e8b 100755 --- a/.kent/scripts/workflow-puber-release-intent +++ b/.kent/scripts/workflow-puber-release-intent @@ -34,9 +34,15 @@ TAG = re.compile(r"^v\d+\.\d+\.\d+$") HEX64 = re.compile(r"^[0-9a-f]{64}$") NOT_SELECTED_RUN = {"schema": "puber_release_run_v1", "state": "not_selected"} RUN_KEYS = {"schema", "state", "repository", "workflow_path", "workflow_name", "event", "run_id", "attempt", "head_sha", "ref", "head_branch"} -PUBLICATION_KEYS = {"schema", "operation_digest", "pr_url", "release_version", "release_tag", "target_commit", "notes_report", "notes_digest", "tag_phase", "tag_status", "unresolved"} -RELEASE_KEYS = {"schema", "run", "jobs", "release", "assets", "checksum", "notes_digest", "publication_report_digest", "unresolved"} +PUBLICATION_KEYS = {"schema", "operation_digest", "previous_operation_digest", "pr_url", "release_version", "release_tag", "target_commit", "notes_report", "notes_digest", "tag_phase", "tag_status", "unresolved"} +RELEASE_KEYS = {"schema", "observation_operation_digest", "observation_provenance_fingerprint", "run", "jobs", "release", "assets", "checksum", "notes_digest", "publication_report_digest", "unresolved"} CLEANUP_KEYS = {"schema", "operation_digest", "cleanup_session_id", "cleanup_mode", "publication_report", "publication_report_digest", "release_report", "release_report_digest", "release_notes_path", "release_notes_digest", "unresolved", "destructive_action", "blocker_reason"} +NOTES_KEYS = {"schema", "path", "mode", "bytes", "sha256", "encoding", "line_endings", "final_newline", "producer", "previous_tag", "range", "target_commit"} +RUN_API_KEYS = {"run_id", "attempt", "path", "name", "event", "head_sha", "head_branch", "status", "conclusion"} +JOB_KEYS = {"id", "name", "status", "conclusion", "attempt"} +RELEASE_METADATA_KEYS = {"tag_name", "target_commitish", "name", "draft", "prerelease", "html_url", "body", "remote_tag_target"} +ASSET_KEYS = {"id", "name", "size", "browser_download_url", "download_sha256"} +CHECKSUM_KEYS = {"schema", "apk_asset_id", "apk_name", "apk_size", "apk_download_sha256", "checksum_asset_id", "checksum_name", "checksum_size", "checksum_download_sha256", "parsed_checksum_digest", "parsed_checksum_filename", "download_sizes_match", "apk_checksum_equal"} class ContractError(ValueError): pass @@ -142,6 +148,37 @@ def _kent_bin(root: Path) -> str: raise ContractError("stock Kent executable is unavailable") return candidate +def _kent_worktree_bin() -> str: + candidate = os.environ.get("PUBER_KENT_WORKTREE_BIN", "") or str(Path.home() / ".kent/bin/kent-worktree") + if not Path(candidate).is_file() or not os.access(candidate, os.X_OK): + raise ContractError("stock Kent worktree wrapper is unavailable") + return candidate + +def session_worktree_status(root: Path, session_id: str) -> dict[str, Any]: + session_id = require_string(session_id, "cleanup_session_id") + if any(character.isspace() for character in session_id) or "/" in session_id or "\\" in session_id: + raise ContractError("cleanup_session_id is not safe") + result = run([_kent_worktree_bin(), "status", "--session", session_id, "--json"], cwd=root, env=child_env(), check=False) + if result.returncode != 0: + raise ContractError(result.stderr.strip() or result.stdout.strip() or "Kent worktree status failed") + return _json_one(result.stdout.encode("utf-8")) + +def session_targets_worktree(root: Path, session_id: str) -> bool: + payload = session_worktree_status(root, session_id) + target = payload.get("target") + worktree = payload.get("worktree") + if not isinstance(target, dict) or not isinstance(worktree, dict): + raise ContractError("Kent worktree status is missing target/worktree objects") + values = { + "effective_target": target.get("EffectiveWorkdir"), + "recorded_target": worktree.get("recorded_root"), + "observed_target": worktree.get("observed_root"), + } + if any(not isinstance(value, str) or not value.strip() for value in values.values()): + raise ContractError("Kent worktree status has incomplete target evidence") + normalized = {key: Path(value).expanduser().resolve() for key, value in values.items()} + return all(value == root for value in normalized.values()) + def task_show(root: Path, task_id: str) -> dict[str, Any]: result = run([_kent_bin(root), "task", "show", task_id, "--project", PROJECT_ID, "--json"], cwd=root, env=child_env(), check=False) if result.returncode != 0: @@ -365,7 +402,9 @@ def attest(root: Path, state: dict[str, Any], node_key: str, branch: str, *, kin binding = kit["capture_runtime_authority_binding"](selected, captures, context, authority) canonicalized = None if kind: - variant_key = "publish_after_merge" if kind == "publish" else "observe_release" + variant_key = {"publish": "publish_after_merge", "observe": "observe_release", "observe_release": "observe_release"}.get(kind) + if variant_key is None: + raise ContractError(f"unsupported operation kind: {kind}") variant = next((item for item in spec.operation_variants if item.key == variant_key), None) if variant is None: raise ContractError(f"missing ReleaseSpec operation variant: {variant_key}") @@ -456,7 +495,10 @@ def remote_tag(root: Path, tag: str) -> str: raise ContractError("remote tag does not resolve to an exact commit") def remote_release_tags(root: Path) -> list[str]: - output = git(root, "ls-remote", "--tags", "origin", "refs/tags/v*", check=False) + result = run(["git", "ls-remote", "--tags", "origin", "refs/tags/v*"], cwd=root, check=False) + if result.returncode != 0: + raise ContractError(result.stderr.strip() or "exact remote release tag line is unavailable") + output = result.stdout tags = set() for line in output.splitlines(): fields = line.split() @@ -489,8 +531,8 @@ def selected_run_context(run: Mapping[str, Any]) -> dict[str, Any]: return {key: run[key] for key in ("repository", "workflow_path", "workflow_name", "event", "run_id", "attempt", "head_sha", "ref")} def parse_notes_report(raw: Any, root: Path, task_short_id: str, *, expected_tag: str | None = None, expected_target: str | None = None) -> dict[str, Any]: - value = raw if isinstance(raw, dict) else parse_closed_json(raw, "puber_release_notes_v1", {"schema", "path", "mode", "bytes", "sha256", "encoding", "line_endings", "final_newline", "producer", "previous_tag", "range", "target_commit"}) - if set(value) != {"schema", "path", "mode", "bytes", "sha256", "encoding", "line_endings", "final_newline", "producer", "previous_tag", "range", "target_commit"} or value.get("schema") != "puber_release_notes_v1": + value = raw if isinstance(raw, dict) else parse_closed_json(raw, "puber_release_notes_v1", NOTES_KEYS) + if set(value) != NOTES_KEYS or value.get("schema") != "puber_release_notes_v1": raise ContractError("notes report is not closed") path = validate_notes_path(root, value.get("path"), task_short_id=task_short_id) if value.get("mode") != 0o600 or not isinstance(value.get("bytes"), int) or value["bytes"] <= 0 or not HEX64.fullmatch(str(value.get("sha256", ""))) or value.get("encoding") != "UTF-8" or value.get("line_endings") != "LF" or value.get("final_newline") is not True or value.get("producer") != ".kent/scripts/workflow-puber-release-publish": @@ -498,7 +540,15 @@ def parse_notes_report(raw: Any, root: Path, task_short_id: str, *, expected_tag target = require_string(value.get("target_commit"), "notes target_commit") if not SHA1.fullmatch(target) or expected_target and target != expected_target: raise ContractError("notes target commit is not exact") - if expected_tag and value.get("range") != (f"{value['previous_tag']}..{target}" if value.get("previous_tag") else target): + previous = value.get("previous_tag") + if previous: + if not TAG.fullmatch(previous) or expected_tag and tuple(int(part) for part in previous[1:].split(".")) >= tuple(int(part) for part in expected_tag[1:].split(".")): + raise ContractError("notes previous_tag is not strictly lower than the release tag") + if previous not in remote_release_tags(root): + raise ContractError("notes previous_tag is not present in the exact remote release tag line") + elif previous != "": + raise ContractError("notes previous_tag must be empty or an exact release tag") + if expected_tag and value.get("range") != (f"{previous}..{target}" if previous else target): raise ContractError("notes range is not exact") body = (root / path).read_bytes() mode = stat.S_IMODE((root / path).stat().st_mode) @@ -506,10 +556,12 @@ def parse_notes_report(raw: Any, root: Path, task_short_id: str, *, expected_tag raise ContractError("notes bytes/readback are not canonical") return dict(value) -def publication_report(operation_digest: str, pr_url: str, version: str, tag: str, target: str, notes: Mapping[str, Any], *, tag_phase: str, tag_status: str, unresolved: bool) -> tuple[str, str]: +def publication_report(operation_digest: str, pr_url: str, version: str, tag: str, target: str, notes: Mapping[str, Any], *, tag_phase: str, tag_status: str, unresolved: bool, previous_operation_digest: str = "") -> tuple[str, str]: if not HEX64.fullmatch(operation_digest): raise ContractError("operation digest is not exact") - report = {"schema": "puber_publication_report_v1", "operation_digest": operation_digest, "pr_url": pr_url, "release_version": version, "release_tag": tag, "target_commit": target, "notes_report": dict(notes), "notes_digest": notes["sha256"], "tag_phase": tag_phase, "tag_status": tag_status, "unresolved": unresolved} + if previous_operation_digest and not HEX64.fullmatch(previous_operation_digest): + raise ContractError("previous operation digest is not exact") + report = {"schema": "puber_publication_report_v1", "operation_digest": operation_digest, "previous_operation_digest": previous_operation_digest, "pr_url": pr_url, "release_version": version, "release_tag": tag, "target_commit": target, "notes_report": dict(notes), "notes_digest": notes["sha256"], "tag_phase": tag_phase, "tag_status": tag_status, "unresolved": unresolved} encoded = canonical_json(report) return encoded, sha256_bytes(encoded.encode("utf-8")) @@ -534,12 +586,86 @@ def parse_publication_report(raw: Any, digest: Any, *, expected_pr: str | None = parse_notes_report(value["notes_report"], root, task_short_id, expected_tag=value["release_tag"], expected_target=value["target_commit"]) if value.get("notes_digest") != value["notes_report"].get("sha256"): raise ContractError("publication report notes digest mismatch") + previous_operation_digest = value.get("previous_operation_digest") + if previous_operation_digest and not HEX64.fullmatch(previous_operation_digest): + raise ContractError("publication report previous operation digest is invalid") + if previous_operation_digest == value.get("operation_digest"): + raise ContractError("publication report operation continuity is circular") return dict(value) -def parse_release_report(raw: Any, digest: Any) -> dict[str, Any]: +def parse_run_api(value: Any) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != RUN_API_KEYS: + raise ContractError("release run API evidence is not closed") + if not isinstance(value["run_id"], int) or value["run_id"] <= 0 or not isinstance(value["attempt"], int) or value["attempt"] <= 0: + raise ContractError("release run API IDs are invalid") + if value["path"] != ".github/workflows/release.yml" or value["name"] != "Release" or value["event"] != "push" or not SHA1.fullmatch(str(value["head_sha"])) or not TAG.fullmatch(str(value["head_branch"])) or value["status"] != "completed" or value["conclusion"] != "success": + raise ContractError("release run API identity/outcome is invalid") + return dict(value) + +def parse_job(value: Any, attempt: int) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != JOB_KEYS or value.get("name") != "Build & Publish" or value.get("status") != "completed" or value.get("conclusion") != "success" or not isinstance(value.get("id"), int) or value["id"] <= 0 or value.get("attempt") != attempt: + raise ContractError("release job evidence is not exact") + return dict(value) + +def parse_checksum_proof(value: Any, assets: list[dict[str, Any]]) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != CHECKSUM_KEYS or value.get("schema") != "puber_checksum_proof_v1" or value.get("download_sizes_match") is not True or value.get("apk_checksum_equal") is not True: + raise ContractError("checksum proof is not closed") + apk = next((asset for asset in assets if asset["name"].endswith(".apk")), None) + checksum = next((asset for asset in assets if asset["name"].endswith(".apk.sha256")), None) + if apk is None or checksum is None: + raise ContractError("checksum proof assets are incomplete") + if value["apk_asset_id"] != apk["id"] or value["apk_name"] != apk["name"] or value["apk_size"] != apk["size"] or value["checksum_asset_id"] != checksum["id"] or value["checksum_name"] != checksum["name"] or value["checksum_size"] != checksum["size"]: + raise ContractError("checksum proof asset identity drifted") + for key in ("apk_download_sha256", "checksum_download_sha256", "parsed_checksum_digest"): + if not HEX64.fullmatch(str(value.get(key, ""))): + raise ContractError("checksum proof digest is invalid") + if value["parsed_checksum_filename"] != apk["name"]: + raise ContractError("checksum proof filename is invalid") + if value["apk_download_sha256"] != apk["download_sha256"] or value["checksum_download_sha256"] != checksum["download_sha256"] or value["parsed_checksum_digest"] != value["apk_download_sha256"]: + raise ContractError("checksum proof byte digest equality is invalid") + return dict(value) + +def parse_release_metadata(value: Any, expected_tag: str | None = None, expected_target: str | None = None) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != RELEASE_METADATA_KEYS or value.get("target_commitish") != "master" or value.get("draft") is not False or value.get("prerelease") is not False: + raise ContractError("release metadata is not the actual workflow shape") + safe_version_tag(value.get("tag_name", "")[1:], value.get("tag_name", "")) + if expected_tag and value["tag_name"] != expected_tag or expected_target and value.get("remote_tag_target") != expected_target: + raise ContractError("release metadata tag/target drifted") + if value.get("name") != f"Puber {value['tag_name']}" or value.get("html_url") != f"https://github.com/{REPOSITORY}/releases/tag/{value['tag_name']}" or not isinstance(value.get("body"), str) or not SHA1.fullmatch(str(value.get("remote_tag_target", ""))): + raise ContractError("release metadata identity is invalid") + return dict(value) + +def parse_release_report(raw: Any, digest: Any, *, expected_publication_digest: str | None = None, expected_notes_digest: str | None = None, expected_tag: str | None = None, expected_target: str | None = None) -> dict[str, Any]: value = raw if isinstance(raw, dict) else parse_closed_json(raw, "puber_release_report_v1", RELEASE_KEYS) - if set(value) != RELEASE_KEYS or value.get("schema") != "puber_release_report_v1" or not isinstance(digest, str) or digest != sha256_bytes(canonical_json(value).encode("utf-8")) or not isinstance(value.get("unresolved"), bool): + if set(value) != RELEASE_KEYS or value.get("schema") != "puber_release_report_v1" or not isinstance(digest, str) or digest != sha256_bytes(canonical_json(value).encode("utf-8")) or value.get("unresolved") is not False: raise ContractError("release report is not closed or its digest mismatches") + if not HEX64.fullmatch(str(value.get("observation_operation_digest", ""))) or not HEX64.fullmatch(str(value.get("observation_provenance_fingerprint", ""))): + raise ContractError("release observation authority evidence is missing or invalid") + run = parse_run_carrier(value["run"]) + if run == NOT_SELECTED_RUN: + raise ContractError("release report run is not selected") + if not isinstance(value.get("jobs"), dict) or set(value["jobs"]) != {"run", "job"}: + raise ContractError("release report jobs are not closed") + run_api = parse_run_api(value["jobs"]["run"]) + job = parse_job(value["jobs"]["job"], run["attempt"]) + if run_api["run_id"] != run["run_id"] or run_api["attempt"] != run["attempt"] or run_api["head_sha"] != run["head_sha"] or run_api["head_branch"] != run["head_branch"]: + raise ContractError("release report run API evidence drifted") + metadata = parse_release_metadata(value["release"], expected_tag=expected_tag, expected_target=expected_target) + assets = value["assets"] + if not isinstance(assets, list) or len(assets) != 2 or any(not isinstance(asset, dict) or set(asset) != ASSET_KEYS for asset in assets): + raise ContractError("release report assets are not closed") + if {asset["name"] for asset in assets} != {f"puber-{metadata['tag_name']}.apk", f"puber-{metadata['tag_name']}.apk.sha256"}: + raise ContractError("release report asset names are not exact") + for asset in assets: + if not isinstance(asset["id"], int) or asset["id"] <= 0 or not isinstance(asset["size"], int) or asset["size"] <= 0 or not HEX64.fullmatch(str(asset["download_sha256"])) or not isinstance(asset["browser_download_url"], str) or not asset["browser_download_url"].startswith("https://"): + raise ContractError("release report asset evidence is invalid") + checksum = parse_checksum_proof(value["checksum"], assets) + if expected_target and metadata["remote_tag_target"] != expected_target or metadata.get("tag_name") != (expected_tag or metadata.get("tag_name")) or metadata.get("body") is None: + raise ContractError("release report metadata proof is incomplete") + if not HEX64.fullmatch(str(value.get("notes_digest", ""))) or not HEX64.fullmatch(str(value.get("publication_report_digest", ""))): + raise ContractError("release report carrier digests are invalid") + if expected_publication_digest and value["publication_report_digest"] != expected_publication_digest or expected_notes_digest and value["notes_digest"] != expected_notes_digest: + raise ContractError("release report carrier digest drifted") return dict(value) def notes_report(root: Path, task_short_id: str, tag: str, target: str) -> dict[str, Any]: @@ -549,10 +675,15 @@ def notes_report(root: Path, task_short_id: str, tag: str, target: str) -> dict[ raise ContractError("notes target is not exact") path_value = f".todo/{task_short_id}/release-notes-ru.md" path = root / path_value - previous_values = [value for value in remote_release_tags(root) if value != tag and tuple(int(part) for part in value[1:].split(".")) < tuple(int(part) for part in tag[1:].split("."))] + remote_tags = remote_release_tags(root) + current_version = tuple(int(part) for part in tag[1:].split(".")) + previous_values = [value for value in remote_tags if value != tag and tuple(int(part) for part in value[1:].split(".")) < current_version] previous = previous_values[-1] if previous_values else "" revision = f"{previous}..{target}" if previous else target - subjects = git(root, "log", "--format=%s", revision, check=False).splitlines()[:100] + log_result = run(["git", "log", "--format=%s", revision], cwd=root, check=False) + if log_result.returncode != 0: + raise ContractError("exact release notes range cannot be resolved") + subjects = log_result.stdout.splitlines()[:100] body = ("puber_release_notes_v1\n\n# Изменения\n\n" + "\n".join(f"- {subject}" for subject in subjects) + "\n").encode("utf-8") if not re.search(r"[А-Яа-яЁё]", body.decode("utf-8")): body += "- Изменения релиза подтверждены.\n".encode("utf-8") @@ -635,7 +766,10 @@ def read_release_run(root: Path, run: Mapping[str, Any], target: str, tag: str) def read_release(root: Path, tag: str, target: str) -> dict[str, Any]: release = gh_api(root, f"repos/{REPOSITORY}/releases/tags/{tag}") - if not isinstance(release, dict) or release.get("tag_name") != tag or release.get("target_commitish") != target or release.get("name") != f"Puber {tag}" or release.get("draft") is not False or release.get("prerelease") is not False or release.get("html_url") != f"https://github.com/{REPOSITORY}/releases/tag/{tag}": + remote_target = remote_tag(root, tag) + if remote_target != target: + raise ContractError("remote release tag does not resolve to the exact target") + if not isinstance(release, dict) or release.get("tag_name") != tag or release.get("target_commitish") != "master" or release.get("name") != f"Puber {tag}" or release.get("draft") is not False or release.get("prerelease") is not False or release.get("html_url") != f"https://github.com/{REPOSITORY}/releases/tag/{tag}": raise ContractError("GitHub Release identity/state is not exact") assets = release.get("assets") if not isinstance(assets, list) or len(assets) != 2: @@ -645,9 +779,9 @@ def read_release(root: Path, tag: str, target: str) -> dict[str, Any]: raise ContractError("GitHub Release asset names are not exact") normalized_assets = [] for asset in assets: - if not isinstance(asset, dict) or not isinstance(asset.get("id"), int) or asset["id"] <= 0 or not isinstance(asset.get("size"), int) or asset["size"] <= 0 or not isinstance(asset.get("name"), str): + if not isinstance(asset, dict) or not isinstance(asset.get("id"), int) or asset["id"] <= 0 or not isinstance(asset.get("size"), int) or asset["size"] <= 0 or not isinstance(asset.get("name"), str) or not isinstance(asset.get("browser_download_url"), str) or not asset["browser_download_url"].startswith("https://"): raise ContractError("GitHub Release asset identity/size is invalid") - normalized_assets.append({"id": asset["id"], "name": asset["name"], "size": asset["size"], "browser_download_url": asset.get("browser_download_url", "")}) + normalized_assets.append({"id": asset["id"], "name": asset["name"], "size": asset["size"], "browser_download_url": asset["browser_download_url"]}) apk = next(item for item in normalized_assets if item["name"].endswith(".apk")) checksum = next(item for item in normalized_assets if item["name"].endswith(".apk.sha256")) apk_bytes = gh_api_bytes(root, f"repos/{REPOSITORY}/releases/assets/{apk['id']}") @@ -661,7 +795,26 @@ def read_release(root: Path, tag: str, target: str) -> dict[str, Any]: match = re.fullmatch(r"([0-9a-f]{64})[ \t]+\*?(.+)", lines[0]) if not match or match.group(2) != apk["name"] or match.group(1) != sha256_bytes(apk_bytes): raise ContractError("checksum asset does not match APK bytes") - return {"tag_name": release["tag_name"], "target_commitish": release["target_commitish"], "name": release["name"], "draft": release["draft"], "prerelease": release["prerelease"], "html_url": release["html_url"], "body": release.get("body", ""), "assets": normalized_assets} + apk_download_sha256 = sha256_bytes(apk_bytes) + checksum_download_sha256 = sha256_bytes(checksum_bytes) + for asset in normalized_assets: + asset["download_sha256"] = apk_download_sha256 if asset["id"] == apk["id"] else checksum_download_sha256 + checksum_proof = { + "schema": "puber_checksum_proof_v1", + "apk_asset_id": apk["id"], + "apk_name": apk["name"], + "apk_size": apk["size"], + "apk_download_sha256": apk_download_sha256, + "checksum_asset_id": checksum["id"], + "checksum_name": checksum["name"], + "checksum_size": checksum["size"], + "checksum_download_sha256": checksum_download_sha256, + "parsed_checksum_digest": match.group(1), + "parsed_checksum_filename": match.group(2), + "download_sizes_match": True, + "apk_checksum_equal": True, + } + return {"tag_name": release["tag_name"], "target_commitish": release["target_commitish"], "name": release["name"], "draft": release["draft"], "prerelease": release["prerelease"], "html_url": release["html_url"], "body": release.get("body", ""), "remote_tag_target": remote_target, "assets": normalized_assets, "checksum": checksum_proof} def report_json(value: Mapping[str, Any]) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) diff --git a/.kent/scripts/workflow-puber-release-publish b/.kent/scripts/workflow-puber-release-publish index 311cc731..6489a6f9 100755 --- a/.kent/scripts/workflow-puber-release-publish +++ b/.kent/scripts/workflow-puber-release-publish @@ -74,11 +74,13 @@ def main() -> int: attested = helper.attest(ROOT, state, "publish", edge["key"], kind="publish", project_fields={"release_tag": tag, "target_commit": target, "release_version": version}) operation_digest = attested["operation"].operation_digest tag_status = helper.publish_tag(ROOT, tag, target) - report, report_digest = helper.publication_report(operation_digest, pr_url, version, tag, target, notes, tag_phase="tag_published", tag_status=tag_status, unresolved=True) + previous_operation_digest = "" else: - helper.attest(ROOT, state, "publish", edge["key"], kind=None) + attested = helper.attest(ROOT, state, "publish", edge["key"], kind="publish", project_fields={"release_tag": tag, "target_commit": target, "release_version": version}) + operation_digest = attested["operation"].operation_digest + previous_operation_digest = existing_report["operation_digest"] tag_status = helper.publish_tag(ROOT, tag, target) - report, report_digest = helper.publication_report(existing_report["operation_digest"], pr_url, version, tag, target, notes, tag_phase="tag_published", tag_status=tag_status, unresolved=True) + report, report_digest = helper.publication_report(operation_digest, pr_url, version, tag, target, notes, tag_phase="tag_published", tag_status=tag_status, unresolved=True, previous_operation_digest=previous_operation_digest) fields = { **base, "pr_url": pr_url, diff --git a/.kent/scripts/workflow-release-cleanup b/.kent/scripts/workflow-release-cleanup index 6bfdafc9..0b1bab38 100755 --- a/.kent/scripts/workflow-release-cleanup +++ b/.kent/scripts/workflow-release-cleanup @@ -22,26 +22,78 @@ def _eligible_session(state: dict) -> str: candidates = [] for row in rows: if not isinstance(row, dict): - continue - session_id = row.get("session_id") or row.get("id") - if not isinstance(session_id, str) or not session_id: - continue - if row.get("task_id") not in {None, state["task_id"]} or row.get("worktree_path") not in {None, str(ROOT)}: - continue - if row.get("status") not in {None, "live", "retained", "active"}: - continue - if row.get("task_id") is None or row.get("worktree_path") is None: - continue - candidates.append(session_id) + raise helper.ContractError("Kent live_sessions contains a non-object row") + if set(row) != {"session_id", "session_name", "node_display_name"}: + raise helper.ContractError("Kent live_sessions row is not the stock closed shape") + session_id = helper.require_string(row.get("session_id"), "cleanup_session_id") + helper.require_string(row.get("session_name"), "session_name") + helper.require_string(row.get("node_display_name"), "node_display_name") + if helper.session_targets_worktree(ROOT, session_id): + candidates.append(session_id) if len(candidates) != 1: - raise helper.ContractError("no exact eligible retained/live Kent Session belongs to this task/worktree") + raise helper.ContractError("exactly one stock Kent Session must target this task worktree") return candidates[0] -def _cleanup_report(state: dict, session: str, payload: dict, *, unresolved: bool, blocker: str) -> tuple[str, str]: +def _session_for_cleanup(state: dict, prior_session: str | None = None) -> str: + if prior_session is not None: + prior_session = helper.require_string(prior_session, "cleanup_session_id") + if not helper.session_targets_worktree(ROOT, prior_session): + raise helper.ContractError("prior cleanup Session no longer targets this task worktree") + return prior_session + return _eligible_session(state) + +def _parse_janitor_report(raw: str) -> tuple[dict, str]: + if not isinstance(raw, str) or len(raw.encode("utf-8")) > 2 * 1024 * 1024: + raise helper.ContractError("Janitor cleanup_report is unbounded or not text") + parts = raw.split("\n", 1) + if len(parts) != 2 or not parts[0].strip() or not parts[1].startswith("Janitor ") or not parts[1].strip(): + raise helper.ContractError("Janitor cleanup_report must contain one canonical report line and a bounded Janitor suffix") + previous = helper.parse_closed_json(parts[0], "puber_cleanup_report_v1", helper.CLEANUP_KEYS) + if helper.canonical_json(previous) != parts[0]: + raise helper.ContractError("Janitor cleanup_report first line is not canonical") + return previous, parts[1] + +def _validate_carriers(state: dict, carrier: dict) -> tuple[dict | None, dict | None]: + publication = None + if carrier.get("publication_report"): + publication = helper.parse_publication_report( + carrier["publication_report"], + carrier.get("publication_report_digest"), + task_short_id=state["task_short_id"], + root=ROOT, + ) + if carrier.get("release_notes_path") != publication["notes_report"]["path"] or carrier.get("release_notes_digest") != publication["notes_digest"]: + raise helper.ContractError("cleanup notes carrier does not match publication report") + helper.parse_notes_report( + publication["notes_report"], + ROOT, + state["task_short_id"], + expected_tag=publication["release_tag"], + expected_target=publication["target_commit"], + ) + release = None + if carrier.get("release_report"): + expected_publication = carrier.get("publication_report_digest") or None + expected_notes = carrier.get("release_notes_digest") or None + release = helper.parse_release_report( + carrier["release_report"], + carrier.get("release_report_digest"), + expected_publication_digest=expected_publication, + expected_notes_digest=expected_notes, + expected_tag=publication["release_tag"] if publication else None, + expected_target=publication["target_commit"] if publication else None, + ) + if publication and release["publication_report_digest"] != carrier["publication_report_digest"]: + raise helper.ContractError("cleanup release/publication report digest mismatch") + if release["notes_digest"] != carrier.get("release_notes_digest"): + raise helper.ContractError("cleanup release/notes digest mismatch") + return publication, release + +def _cleanup_report(state: dict, session: str, payload: dict, *, operation_digest: str, unresolved: bool, blocker: str) -> tuple[str, str]: publication = payload.get("publication_report", "") release = payload.get("release_report", "") notes_path = payload.get("release_notes_path", "") - value = {"schema": "puber_cleanup_report_v1", "operation_digest": payload.get("operation_id", ""), "cleanup_session_id": session, "cleanup_mode": "report_only", "publication_report": publication, "publication_report_digest": payload.get("publication_report_digest", ""), "release_report": release, "release_report_digest": payload.get("release_report_digest", ""), "release_notes_path": notes_path, "release_notes_digest": payload.get("release_notes_digest", ""), "unresolved": unresolved, "destructive_action": False, "blocker_reason": blocker} + value = {"schema": "puber_cleanup_report_v1", "operation_digest": operation_digest, "cleanup_session_id": session, "cleanup_mode": "report_only", "publication_report": publication, "publication_report_digest": payload.get("publication_report_digest", ""), "release_report": release, "release_report_digest": payload.get("release_report_digest", ""), "release_notes_path": notes_path, "release_notes_digest": payload.get("release_notes_digest", ""), "unresolved": unresolved, "destructive_action": False, "blocker_reason": blocker} encoded = helper.canonical_json(value) return encoded, helper.sha256_bytes(encoded.encode("utf-8")) @@ -52,36 +104,34 @@ def main() -> int: except helper.ContractError as error: print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr) return 2 - prior_unresolved = False - prior_blocker = "" try: + prior_unresolved = False + prior_blocker = "" + prior_session = None if edge["key"] == "task_janitor_blocked": - previous = helper.parse_closed_json(payload["cleanup_report"], "puber_cleanup_report_v1", helper.CLEANUP_KEYS) - prior_session = previous["cleanup_session_id"] - if not isinstance(prior_session, str) or not prior_session: - raise helper.ContractError("prior cleanup report has no exact Session") - session = _eligible_session(state) - if session != prior_session: - raise helper.ContractError("prior cleanup Session is not the exact current eligible Session") - if previous["publication_report"]: - helper.parse_publication_report(previous["publication_report"], previous["publication_report_digest"], task_short_id=state["task_short_id"], root=ROOT) - if previous["release_report"]: - helper.parse_release_report(previous["release_report"], previous["release_report_digest"]) - if previous["release_notes_path"]: - helper.validate_notes_path(ROOT, previous["release_notes_path"], task_short_id=state["task_short_id"]) - carrier = {**payload, "publication_report": previous["publication_report"], "publication_report_digest": previous["publication_report_digest"], "release_report": previous["release_report"], "release_report_digest": previous["release_report_digest"], "release_notes_path": previous["release_notes_path"], "release_notes_digest": previous["release_notes_digest"], "operation_id": previous["operation_digest"]} + previous, janitor_suffix = _parse_janitor_report(payload["cleanup_report"]) + prior_session = helper.require_string(previous["cleanup_session_id"], "cleanup_session_id") + carrier = { + **payload, + "branch_name": payload.get("branch_name", ""), + "publication_report": previous["publication_report"], + "publication_report_digest": previous["publication_report_digest"], + "release_report": previous["release_report"], + "release_report_digest": previous["release_report_digest"], + "release_notes_path": previous["release_notes_path"], + "release_notes_digest": previous["release_notes_digest"], + } prior_unresolved = previous["unresolved"] - prior_blocker = previous["blocker_reason"] + prior_blocker = janitor_suffix + fresh = helper.attest(ROOT, state, "cleanup", edge["key"]) + operation_digest = fresh["binding"].provenance_fingerprint + session = _session_for_cleanup(state, prior_session) else: - helper.attest(ROOT, state, "cleanup", edge["key"]) - session = _eligible_session(state) + fresh = helper.attest(ROOT, state, "cleanup", edge["key"]) + operation_digest = fresh["binding"].provenance_fingerprint + session = _session_for_cleanup(state) carrier = payload - if carrier.get("publication_report"): - helper.parse_publication_report(carrier["publication_report"], carrier["publication_report_digest"], task_short_id=state["task_short_id"], root=ROOT) - if carrier.get("release_report"): - helper.parse_release_report(carrier["release_report"], carrier["release_report_digest"]) - if carrier.get("release_notes_path"): - helper.validate_notes_path(ROOT, carrier["release_notes_path"], task_short_id=state["task_short_id"]) + _validate_carriers(state, carrier) declarations = helper.declarations(state["task"].get("body")) version = declarations.get("release_version") branch = carrier.get("branch_name") or declarations.get("release_branch", "") @@ -91,7 +141,7 @@ def main() -> int: raise helper.ContractError("cleanup branch carrier is not safe") carrier["branch_name"] = branch blocker = carrier.get("blocker_reason", "") or prior_blocker - report, report_digest = _cleanup_report(state, session, carrier, unresolved=prior_unresolved or bool(blocker), blocker=blocker) + report, report_digest = _cleanup_report(state, session, carrier, operation_digest=operation_digest, unresolved=prior_unresolved or bool(blocker), blocker=blocker) fields = {"cleanup_report": report, "workspace_path": str(ROOT), "branch_name": branch, "pr_url": carrier.get("pr_url", ""), "cleanup_mode": "report_only", "cleanup_session_id": session, "task_short_id": state["task_short_id"], "publication_report": carrier.get("publication_report", ""), "publication_report_digest": carrier.get("publication_report_digest", ""), "release_report": carrier.get("release_report", ""), "release_report_digest": carrier.get("release_report_digest", ""), "release_notes_path": carrier.get("release_notes_path", ""), "release_notes_digest": carrier.get("release_notes_digest", "")} helper.emit_exact(ROOT, "cleanup_task_janitor", "task_janitor", fields) return 0 diff --git a/.kent/scripts/workflow-wait-github-release b/.kent/scripts/workflow-wait-github-release index 57e1bcf0..e3fd5173 100755 --- a/.kent/scripts/workflow-wait-github-release +++ b/.kent/scripts/workflow-wait-github-release @@ -29,8 +29,21 @@ def _report_carriers(payload: dict, state: dict) -> tuple[dict, dict]: raise helper.ContractError("release_run carrier is stale or switched") return publication, run -def _release_report(run: dict, jobs: dict, release: dict, publication: dict, notes_digest: str, checksum: dict) -> tuple[str, str]: - value = {"schema": "puber_release_report_v1", "run": run, "jobs": jobs, "release": release, "assets": release["assets"], "checksum": checksum, "notes_digest": notes_digest, "publication_report_digest": publication["digest"], "unresolved": False} +def _release_report(run: dict, jobs: dict, release: dict, publication_digest: str, notes_digest: str, observation: dict) -> tuple[str, str]: + metadata = {key: release[key] for key in helper.RELEASE_METADATA_KEYS} + value = { + "schema": "puber_release_report_v1", + "observation_operation_digest": observation["operation"].operation_digest, + "observation_provenance_fingerprint": observation["binding"].provenance_fingerprint, + "run": run, + "jobs": {"run": jobs["run"], "job": jobs["job"]}, + "release": metadata, + "assets": release["assets"], + "checksum": release["checksum"], + "notes_digest": notes_digest, + "publication_report_digest": publication_digest, + "unresolved": False, + } encoded = helper.canonical_json(value) return encoded, helper.sha256_bytes(encoded.encode("utf-8")) @@ -46,6 +59,15 @@ def main() -> int: if run == helper.NOT_SELECTED_RUN: run = helper.discover_release_run(ROOT, payload["release_tag"], payload["target_commit"]) payload["release_run"] = helper.canonical_json(run) + observation = helper.attest( + ROOT, + state, + "monitor", + edge["key"], + kind="observe_release", + run_tuple=helper.selected_run_context(run), + project_fields={"release_tag": payload["release_tag"]}, + ) run_for_api, jobs = helper.read_release_run(ROOT, run, payload["target_commit"], payload["release_tag"]) release = helper.read_release(ROOT, payload["release_tag"], payload["target_commit"]) notes = helper.parse_notes_report(publication["notes_report"], ROOT, state["task_short_id"], expected_tag=payload["release_tag"], expected_target=payload["target_commit"]) @@ -58,10 +80,10 @@ def main() -> int: release = helper.read_release(ROOT, payload["release_tag"], payload["target_commit"]) if release.get("body") != notes_bytes.decode("utf-8"): raise helper.ContractError("release notes post-edit readback mismatch") - final_publication, final_publication_digest = helper.publication_report(publication["operation_digest"], publication["pr_url"], publication["release_version"], publication["release_tag"], publication["target_commit"], notes, tag_phase="tag_published", tag_status=publication["tag_status"], unresolved=False) + final_publication, final_publication_digest = helper.publication_report(publication["operation_digest"], publication["pr_url"], publication["release_version"], publication["release_tag"], publication["target_commit"], notes, tag_phase="tag_published", tag_status=publication["tag_status"], unresolved=False, previous_operation_digest=publication.get("previous_operation_digest", "")) publication_final = helper.parse_publication_report(final_publication, final_publication_digest, expected_pr=publication["pr_url"], expected_version=publication["release_version"], expected_tag=publication["release_tag"], expected_target=publication["target_commit"], task_short_id=state["task_short_id"], root=ROOT) - publication_final["digest"] = final_publication_digest - report, report_digest = _release_report(run, {"run": run_for_api, "job": jobs}, release, publication_final, notes["sha256"], {"apk_sha256": helper.sha256_bytes(helper.gh_api_bytes(ROOT, f"repos/{helper.REPOSITORY}/releases/assets/{next(asset['id'] for asset in release['assets'] if asset['name'].endswith('.apk'))}")), "apk_name": next(asset["name"] for asset in release["assets"] if asset["name"].endswith(".apk")), "checksum_name": next(asset["name"] for asset in release["assets"] if asset["name"].endswith(".apk.sha256"))}) + report, report_digest = _release_report(run, {"run": run_for_api, "job": jobs}, release, final_publication_digest, notes["sha256"], observation) + helper.parse_release_report(report, report_digest, expected_publication_digest=final_publication_digest, expected_notes_digest=notes["sha256"], expected_tag=payload["release_tag"], expected_target=payload["target_commit"]) helper.emit_exact(ROOT, "release_release_published", "cleanup", {"release_report": report, "publication_report": final_publication, "publication_report_digest": final_publication_digest, "release_notes_path": notes["path"], "release_notes_digest": notes["sha256"], "release_run": helper.canonical_json(run), "release_report_digest": report_digest}) return 0 except helper.ContractError as error: diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json index 5f4fe5f2..df2b198e 100644 --- a/.kent/workflows/puber-release.manifest.json +++ b/.kent/workflows/puber-release.manifest.json @@ -61,17 +61,17 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-puber-release-intent=1b4c09101d70a41da0e5bdd2b39edbf78569250ca6c3f3e4ec3e77c00fb43551", + "key": ".kent/scripts/tests/test-workflow-puber-release-intent=c3b476d7d04cf3c303fa04b0bd4ec68fe020e07d6fe0977652f0af6ed8f92be4", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-puber-release-publish=f7cfa26c071638037bf5cc1c5ea6c77b2681a991721d5dffebcc3b9578f08a3a", + "key": ".kent/scripts/tests/test-workflow-puber-release-publish=f5972030528574b2269d2e8761e17c769871d839503f969c28a9506f33b8b564", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-release-cleanup=33e2187493e16017c81b0c03e06c93e8aefb98378777e4c93629b4c3e146e5c7", + "key": ".kent/scripts/tests/test-workflow-release-cleanup=64c2d2ff8144ba1943fdaf53503e094c5c05c7221df0d84ef61dab104fb9d9cf", "runtime_digest_required": true }, { @@ -81,22 +81,22 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-wait-github-release=5edf0a424a14a6c059a56b9a97d37898d8907e290901ed41577884c106115006", + "key": ".kent/scripts/tests/test-workflow-wait-github-release=9cc0fcfa46c42fca87c430a5bf4ede0841890817dda20a68a472e3750996077c", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-puber-release-intent=690b487b178cb17b826ccce1b401c6e00b0c1d46a3e498f006f31438df857263", + "key": ".kent/scripts/workflow-puber-release-intent=6eae030ade14e6c4e310eb8e4732cbf67e41eb998f32f3700fe0a8f265bdbda0", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-release-cleanup=d717a86124ee3dd06c7cd3de96ed4283d0643fb80afe6643da99ef239367dff4", + "key": ".kent/scripts/workflow-release-cleanup=f2e2bb61092dde2581180e172cb37af47a46de536a0a6b2d5a1fd2d3e2f61c57", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-wait-github-release=e39e54cc217723ad3f8f65129a65a25547d1e57934f41d8701f714194d6388a2", + "key": ".kent/scripts/workflow-wait-github-release=e80a040d4061ad643423f9d9776b58eecade83f02e04412182b73beec85d42ac", "runtime_digest_required": true }, { diff --git a/.kent/workflows/specs/puber-release.toml b/.kent/workflows/specs/puber-release.toml index b805505b..c083bae1 100644 --- a/.kent/workflows/specs/puber-release.toml +++ b/.kent/workflows/specs/puber-release.toml @@ -320,7 +320,7 @@ continue_on_error = false runs_on = "ubuntu-latest" runner_trust = "github-hosted-standard-ephemeral-effect" credential_profile = "github-platform-contents-write" -allowed_effects = ["git-tag-create", "publish"] +allowed_effects = ["artifact-upload", "github-release-create"] skip_policy = "event-gated" branch_protection_required = false control_plane_fixtures_forbidden = true From 7deb9502d0aa1358eaa2690dfd58a84143180b22 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 07:03:01 +0500 Subject: [PATCH 11/21] Harden release cleanup evidence contract --- .../tests/test-workflow-release-cleanup | 81 ++++++++++++++----- .kent/scripts/workflow-puber-release-intent | 39 ++++++++- .kent/scripts/workflow-release-cleanup | 81 ++++++++++++++++--- .kent/workflows/puber-release.manifest.json | 6 +- 4 files changed, 168 insertions(+), 39 deletions(-) diff --git a/.kent/scripts/tests/test-workflow-release-cleanup b/.kent/scripts/tests/test-workflow-release-cleanup index a0412823..1ee68d37 100755 --- a/.kent/scripts/tests/test-workflow-release-cleanup +++ b/.kent/scripts/tests/test-workflow-release-cleanup @@ -31,13 +31,18 @@ with tempfile.TemporaryDirectory() as directory: manifest = json.loads((ROOT / ".kent/workflows/puber-release.manifest.json").read_text(encoding="utf-8")) for relative in sorted(set(manifest["additional_paths"]) | { ".kent/scripts/workflow-puber-release-intent", + ".kent/scripts/workflow-evidence-ledger", ".kent/scripts/workflow-release-cleanup", + ".kent/scripts/workflow-task-janitor", + ".kent/scripts/workflow_runtime_contracts.py", ".kent/workflows/puber-release.json", ".kent/workflows/puber-release.manifest.json", ".kent/workflows/specs/puber-release.toml", ".kent/workflows/builders/puber_release.py", }): source = ROOT / relative + if relative == ".kent/scripts/workflow-task-janitor": + source = KIT / "templates/project/workflow-task-janitor" target_path = work / relative target_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, target_path) @@ -46,6 +51,27 @@ with tempfile.TemporaryDirectory() as directory: sh(["git", "add", "."], work) if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=work).returncode != 0: sh(["git", "commit", "--quiet", "-m", "cleanup test fixture"], work) + evidence = { + "node_key": "cleanup", + "evidence_type": "cleanup_fixture", + "summary": "Cleanup fixture entered with an ordinary evidence record.", + "artifacts": ["cleanup-fixture"], + "checks": ["fixture"], + "decisions": ["report_only"], + "context": { + "manifest_path": ".kent/context/implement.md", + "files_read": [".kent/project-contract.md"], + "model_calls": 0, + "compaction_count": 0, + "repeated_questions": 0, + "verification_loops": 0, + }, + } + sh( + [str(work / ".kent/scripts/workflow-evidence-ledger"), "append", "--task", "PUB-99", "--workspace", str(work)], + work, + input=json.dumps(evidence), + ) target = sh(["git", "rev-parse", "HEAD"], work).stdout.strip() graph = json.loads((work / ".kent/workflows/puber-release.json").read_text(encoding="utf-8")) cleanup_node = next(node for node in graph["nodes"] if node["key"] == "cleanup") @@ -76,22 +102,18 @@ with tempfile.TemporaryDirectory() as directory: kent.write_text("#!/bin/sh\ncat \"$PUBER_FAKE_TASK\"\n", encoding="utf-8") kent.chmod(0o755) wrapper = fake / "kent-worktree" - status_payload = json.dumps( - { - "target": {"EffectiveWorkdir": str(work)}, - "worktree": {"recorded_root": str(work), "observed_root": str(work)}, - }, - separators=(",", ":"), - ) + moved = fake / "moved" wrapper.write_text( "#!/bin/sh\n" "test \"$1\" = status && test \"$2\" = --session && test \"$4\" = --json\n" - f"printf '%s\\n' '{status_payload}'\n", + f"if test -f '{moved}'; then effective='/tmp/pub-99-moved'; else effective='{work}'; fi\n" + f"printf '{{\"target\":{{\"EffectiveWorkdir\":\"%s\"}},\"worktree\":{{\"recorded_root\":\"{work}\",\"observed_root\":\"{work}\"}}}}\\n' \"$effective\"\n", encoding="utf-8", ) wrapper.chmod(0o755) env = { **os.environ, + "PYTHONDONTWRITEBYTECODE": "1", "KENT_ENGINEERING_KIT_ROOT": str(KIT), "PUBER_KENT_BIN": str(kent), "PUBER_KENT_WORKTREE_BIN": str(wrapper), @@ -113,31 +135,48 @@ with tempfile.TemporaryDirectory() as directory: stderr=subprocess.PIPE, env=env, ) - assert result.returncode == 0, result.stderr + assert result.returncode == 0, result.stderr + "\n" + subprocess.run(["git", "status", "--porcelain=v1", "--untracked-files=all"], cwd=work, text=True, stdout=subprocess.PIPE).stdout output = json.loads(result.stdout) assert output["transition"] == "cleanup_task_janitor" assert output["cleanup_session_id"] == session_id - report = json.loads(output["cleanup_report"]) + report_lines = output["cleanup_report"].splitlines() + assert len(report_lines) == 2 and report_lines[1].startswith("TERMINAL_EVIDENCE_V1 ") + report = json.loads(report_lines[0]) assert report["operation_digest"] != "" assert report["destructive_action"] is False and report["unresolved"] is False task["live_sessions"] = [] task_file.write_text(json.dumps(task), encoding="utf-8") - previous, _ = mod._cleanup_report( - {"task_id": task_id}, - session_id, - {"operation_id": "a" * 64}, - operation_digest="a" * 64, - unresolved=False, - blocker="", + moved.touch() + janitor = subprocess.run( + [str(work / ".kent/scripts/workflow-task-janitor")], + cwd=work, + input=json.dumps( + { + "workspace_path": str(work), + "branch_name": "", + "pr_url": "", + "cleanup_mode": "report_only", + "cleanup_session_id": session_id, + "cleanup_report": output["cleanup_report"], + "task_short_id": "PUB-99", + } + ), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, ) + assert janitor.returncode == 0, janitor.stderr + janitor_output = json.loads(janitor.stdout) + assert janitor_output["cleanup_report"].splitlines()[1] == report_lines[1] blocked = { "_kent": {"task_id": task_id, "node_id": cleanup_node["id"], "transition_branch_key": "task_janitor_blocked"}, "workspace_path": str(work), "operation_id": "a" * 64, "branch_name": "release/99.0.0", "cleanup_mode": "report_only", - "cleanup_report": previous + "\nJanitor preserved the worktree for retry.", + "cleanup_report": janitor_output["cleanup_report"], } result = subprocess.run( [str(work / ".kent/scripts/workflow-release-cleanup")], @@ -148,14 +187,16 @@ with tempfile.TemporaryDirectory() as directory: stderr=subprocess.PIPE, env=env, ) - assert result.returncode == 0, result.stderr + assert result.returncode == 0, result.stderr + "\n" + subprocess.run(["git", "status", "--porcelain=v1", "--untracked-files=all"], cwd=work, text=True, stdout=subprocess.PIPE).stdout retry = json.loads(result.stdout) - retry_report = json.loads(retry["cleanup_report"]) + retry_lines = retry["cleanup_report"].splitlines() + retry_report = json.loads(retry_lines[0]) assert retry["transition"] == "cleanup_task_janitor" assert retry["cleanup_session_id"] == session_id assert retry_report["unresolved"] is True assert retry_report["blocker_reason"].startswith("Janitor ") assert retry_report["operation_digest"] != "a" * 64 + assert retry_lines[1] == report_lines[1] invalid = dict(cancel) invalid["cleanup_reason"] = "" diff --git a/.kent/scripts/workflow-puber-release-intent b/.kent/scripts/workflow-puber-release-intent index 86087e8b..da66efb4 100755 --- a/.kent/scripts/workflow-puber-release-intent +++ b/.kent/scripts/workflow-puber-release-intent @@ -179,6 +179,26 @@ def session_targets_worktree(root: Path, session_id: str) -> bool: normalized = {key: Path(value).expanduser().resolve() for key, value in values.items()} return all(value == root for value in normalized.values()) +def session_preserves_worktree_identity(root: Path, session_id: str) -> bool: + payload = session_worktree_status(root, session_id) + target = payload.get("target") + worktree = payload.get("worktree") + if not isinstance(target, dict) or not isinstance(worktree, dict): + raise ContractError("Kent worktree status is missing target/worktree objects") + recorded = worktree.get("recorded_root") + observed = worktree.get("observed_root") + effective = target.get("EffectiveWorkdir") + if any(not isinstance(value, str) or not value.strip() for value in (recorded, observed, effective)): + raise ContractError("Kent worktree status has incomplete retry identity evidence") + recorded_path = Path(recorded).expanduser().resolve() + observed_path = Path(observed).expanduser().resolve() + effective_path = Path(effective).expanduser().resolve() + if recorded_path != root or observed_path != root: + return False + if effective_path == root or root in effective_path.parents: + return False + return True + def task_show(root: Path, task_id: str) -> dict[str, Any]: result = run([_kent_bin(root), "task", "show", task_id, "--project", PROJECT_ID, "--json"], cwd=root, env=child_env(), check=False) if result.returncode != 0: @@ -278,7 +298,10 @@ def _kit_modules() -> dict[str, Any]: path = Path(importlib.import_module(name).__file__).resolve() if kit_root not in path.parents: raise ContractError("Kit import escaped the selected root") - return {name: getattr(workflowkit, name) for name in ("ProjectProfile","ReleaseSpec","NormalizedGitHubWorkflowSourceV1","preflight_project_revision","capture_runtime_execution_context","capture_runtime_authority_binding","validate_operation_jobs","canonicalize_publication_operation")} + runtime = importlib.import_module("workflowkit.runtime") + result = {name: getattr(workflowkit, name) for name in ("ProjectProfile","ReleaseSpec","NormalizedGitHubWorkflowSourceV1","preflight_project_revision","capture_runtime_execution_context","capture_runtime_authority_binding","validate_operation_jobs","canonicalize_publication_operation")} + result.update({name: getattr(runtime, name) for name in ("validate_terminal_seal_request","validate_terminal_marker","parse_terminal_marker_line","terminal_marker_line","validate_cleanup_report")}) + return result def _external_captures(root: Path, profile: Any, selected: Any) -> list[tuple[str, str, bytes]]: captures = [] @@ -578,8 +601,15 @@ def parse_publication_report(raw: Any, digest: Any, *, expected_pr: str | None = raise ContractError("publication report PR/target drifted") if not SHA1.fullmatch(str(value.get("target_commit", ""))) or value.get("pr_url") and canonical_pr_url(value["pr_url"]) != value["pr_url"]: raise ContractError("publication report identity is invalid") - if value.get("tag_phase") not in {"prepared", "tag_published"} or not isinstance(value.get("tag_status"), str) or not isinstance(value.get("unresolved"), bool): + phase = value.get("tag_phase") + tag_status = value.get("tag_status") + unresolved = value.get("unresolved") + if phase not in {"prepared", "tag_published"} or not isinstance(tag_status, str) or not isinstance(unresolved, bool): raise ContractError("publication report phase is invalid") + if phase == "prepared" and (tag_status != "prepared" or unresolved is not True): + raise ContractError("prepared publication report status is not exact") + if phase == "tag_published" and tag_status not in {"already_present_exact", "created_and_pushed", "local_tag_pushed", "push_ack_recovered"}: + raise ContractError("tag-published publication report status is not exact") if not isinstance(value.get("notes_report"), dict): raise ContractError("publication report notes report is not an object") if root and task_short_id: @@ -788,7 +818,10 @@ def read_release(root: Path, tag: str, target: str) -> dict[str, Any]: checksum_bytes = gh_api_bytes(root, f"repos/{REPOSITORY}/releases/assets/{checksum['id']}") if len(apk_bytes) != apk["size"] or len(checksum_bytes) != checksum["size"]: raise ContractError("downloaded release asset sizes do not match metadata") - checksum_text = checksum_bytes.decode("utf-8") + try: + checksum_text = checksum_bytes.decode("utf-8") + except UnicodeDecodeError as error: + raise ContractError("checksum asset is not valid UTF-8") from error lines = checksum_text.splitlines() if len(lines) != 1: raise ContractError("checksum asset is not one exact line") diff --git a/.kent/scripts/workflow-release-cleanup b/.kent/scripts/workflow-release-cleanup index 0b1bab38..eff2d276 100755 --- a/.kent/scripts/workflow-release-cleanup +++ b/.kent/scripts/workflow-release-cleanup @@ -37,21 +37,32 @@ def _eligible_session(state: dict) -> str: def _session_for_cleanup(state: dict, prior_session: str | None = None) -> str: if prior_session is not None: prior_session = helper.require_string(prior_session, "cleanup_session_id") - if not helper.session_targets_worktree(ROOT, prior_session): - raise helper.ContractError("prior cleanup Session no longer targets this task worktree") + if not helper.session_preserves_worktree_identity(ROOT, prior_session): + raise helper.ContractError("prior cleanup Session no longer preserves this task worktree identity") return prior_session return _eligible_session(state) -def _parse_janitor_report(raw: str) -> tuple[dict, str]: +def _parse_janitor_report(raw: str, task_short_id: str) -> tuple[dict, str, str]: if not isinstance(raw, str) or len(raw.encode("utf-8")) > 2 * 1024 * 1024: raise helper.ContractError("Janitor cleanup_report is unbounded or not text") - parts = raw.split("\n", 1) - if len(parts) != 2 or not parts[0].strip() or not parts[1].startswith("Janitor ") or not parts[1].strip(): - raise helper.ContractError("Janitor cleanup_report must contain one canonical report line and a bounded Janitor suffix") - previous = helper.parse_closed_json(parts[0], "puber_cleanup_report_v1", helper.CLEANUP_KEYS) - if helper.canonical_json(previous) != parts[0]: + lines = raw.splitlines() + if len(lines) < 3: + raise helper.ContractError("Janitor cleanup_report must contain a report, terminal marker, and Janitor suffix") + previous = helper.parse_closed_json(lines[0], "puber_cleanup_report_v1", helper.CLEANUP_KEYS) + if helper.canonical_json(previous) != lines[0]: raise helper.ContractError("Janitor cleanup_report first line is not canonical") - return previous, parts[1] + kit = helper._kit_modules() + try: + marker = kit["validate_cleanup_report"]("\n".join(lines[:2])) + marker_line = kit["terminal_marker_line"](marker) + except Exception as error: + raise helper.ContractError(f"Janitor terminal evidence marker is invalid: {error}") from error + if marker["task_short_id"] != task_short_id or marker_line != lines[1]: + raise helper.ContractError("Janitor terminal evidence marker has the wrong task or is not canonical") + suffix = "\n".join(lines[2:]) + if not suffix.startswith("Janitor ") or not suffix.strip() or len(suffix.encode("utf-8")) > 512 * 1024: + raise helper.ContractError("Janitor cleanup_report suffix is not a bounded Janitor blocker") + return previous, marker_line, suffix def _validate_carriers(state: dict, carrier: dict) -> tuple[dict | None, dict | None]: publication = None @@ -89,13 +100,56 @@ def _validate_carriers(state: dict, carrier: dict) -> tuple[dict | None, dict | raise helper.ContractError("cleanup release/notes digest mismatch") return publication, release -def _cleanup_report(state: dict, session: str, payload: dict, *, operation_digest: str, unresolved: bool, blocker: str) -> tuple[str, str]: +def _cleanup_report(state: dict, session: str, payload: dict, *, operation_digest: str, unresolved: bool, blocker: str, fresh: dict, existing_marker: str | None = None) -> tuple[str, str]: publication = payload.get("publication_report", "") release = payload.get("release_report", "") notes_path = payload.get("release_notes_path", "") value = {"schema": "puber_cleanup_report_v1", "operation_digest": operation_digest, "cleanup_session_id": session, "cleanup_mode": "report_only", "publication_report": publication, "publication_report_digest": payload.get("publication_report_digest", ""), "release_report": release, "release_report_digest": payload.get("release_report_digest", ""), "release_notes_path": notes_path, "release_notes_digest": payload.get("release_notes_digest", ""), "unresolved": unresolved, "destructive_action": False, "blocker_reason": blocker} encoded = helper.canonical_json(value) - return encoded, helper.sha256_bytes(encoded.encode("utf-8")) + if existing_marker is None: + operation_digests = [{"kind": "runtime_source", "sha256": fresh["binding"].runtime_source_envelope_digest}] + publication_digest = value["publication_report_digest"] + if publication_digest: + operation_digests.append({"kind": "publication", "sha256": publication_digest}) + operation_digests.sort(key=lambda item: item["kind"]) + request = { + "schema": "terminal-evidence-seal-request-v1", + "operation_report_digests": operation_digests, + "redaction": {"status": "passed", "report_sha256": helper.sha256_bytes(encoded.encode("utf-8"))}, + "retention_class": "cleanup_report_only", + } + try: + kit = fresh["kit"] + kit["validate_terminal_seal_request"](request) + evidence = fresh["profile"].commands["evidence"] + if not isinstance(evidence, str) or Path(evidence).is_absolute() or Path(evidence).as_posix() != evidence: + raise helper.ContractError("profile evidence command is not project-relative") + result = helper.run([str(ROOT / evidence), "seal", "--task", state["task_short_id"], "--workspace", str(ROOT)], cwd=ROOT, env=helper.child_env(), input_text=helper.canonical_json(request)) + sealed = helper._json_one(result.stdout.encode("utf-8")) + if set(sealed) != {"ledger_path", "terminal_marker", "event_hash"} or not helper.HEX64.fullmatch(str(sealed.get("event_hash", ""))): + raise helper.ContractError("terminal evidence seal response is not closed") + marker_line = helper.require_string(sealed.get("terminal_marker"), "terminal_marker") + marker = kit["parse_terminal_marker_line"](marker_line) + if marker["task_short_id"] != state["task_short_id"]: + raise helper.ContractError("terminal marker task identity drifted") + kit["validate_cleanup_report"](encoded + "\n" + marker_line) + except helper.ContractError: + raise + except Exception as error: + raise helper.ContractError(f"terminal evidence seal failed: {error}") from error + else: + marker_line = existing_marker + try: + marker = fresh["kit"]["parse_terminal_marker_line"](marker_line) + if marker["task_short_id"] != state["task_short_id"]: + raise helper.ContractError("preserved terminal marker task identity drifted") + fresh["kit"]["validate_cleanup_report"](encoded + "\n" + marker_line) + except helper.ContractError: + raise + except Exception as error: + raise helper.ContractError(f"preserved terminal evidence marker is invalid: {error}") from error + report = encoded + "\n" + marker_line + return report, helper.sha256_bytes(report.encode("utf-8")) def main() -> int: try: @@ -108,8 +162,9 @@ def main() -> int: prior_unresolved = False prior_blocker = "" prior_session = None + existing_marker = None if edge["key"] == "task_janitor_blocked": - previous, janitor_suffix = _parse_janitor_report(payload["cleanup_report"]) + previous, existing_marker, janitor_suffix = _parse_janitor_report(payload["cleanup_report"], state["task_short_id"]) prior_session = helper.require_string(previous["cleanup_session_id"], "cleanup_session_id") carrier = { **payload, @@ -141,7 +196,7 @@ def main() -> int: raise helper.ContractError("cleanup branch carrier is not safe") carrier["branch_name"] = branch blocker = carrier.get("blocker_reason", "") or prior_blocker - report, report_digest = _cleanup_report(state, session, carrier, operation_digest=operation_digest, unresolved=prior_unresolved or bool(blocker), blocker=blocker) + report, report_digest = _cleanup_report(state, session, carrier, operation_digest=operation_digest, unresolved=prior_unresolved or bool(blocker), blocker=blocker, fresh=fresh, existing_marker=existing_marker) fields = {"cleanup_report": report, "workspace_path": str(ROOT), "branch_name": branch, "pr_url": carrier.get("pr_url", ""), "cleanup_mode": "report_only", "cleanup_session_id": session, "task_short_id": state["task_short_id"], "publication_report": carrier.get("publication_report", ""), "publication_report_digest": carrier.get("publication_report_digest", ""), "release_report": carrier.get("release_report", ""), "release_report_digest": carrier.get("release_report_digest", ""), "release_notes_path": carrier.get("release_notes_path", ""), "release_notes_digest": carrier.get("release_notes_digest", "")} helper.emit_exact(ROOT, "cleanup_task_janitor", "task_janitor", fields) return 0 diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json index df2b198e..9a4343cb 100644 --- a/.kent/workflows/puber-release.manifest.json +++ b/.kent/workflows/puber-release.manifest.json @@ -71,7 +71,7 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-release-cleanup=64c2d2ff8144ba1943fdaf53503e094c5c05c7221df0d84ef61dab104fb9d9cf", + "key": ".kent/scripts/tests/test-workflow-release-cleanup=6036a61f11af0d580644d12d590b4ddca2981faa2f40efe6e50919545959baf9", "runtime_digest_required": true }, { @@ -86,12 +86,12 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-puber-release-intent=6eae030ade14e6c4e310eb8e4732cbf67e41eb998f32f3700fe0a8f265bdbda0", + "key": ".kent/scripts/workflow-puber-release-intent=e63856f440d4525d5a1e9ce1cb7183ce55cdc76299d6710c8e59ecedd0d25919", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-release-cleanup=f2e2bb61092dde2581180e172cb37af47a46de536a0a6b2d5a1fd2d3e2f61c57", + "key": ".kent/scripts/workflow-release-cleanup=1521d3e59d913ca2e401718c860d4351807a51d167e407467bca377af4c1e5b1", "runtime_digest_required": true }, { From 30a3463f958860f9c6fe96a62e68a638cf737ef2 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 07:43:06 +0500 Subject: [PATCH 12/21] Correct release cleanup lifecycle and adversarial coverage --- .../tests/test-workflow-puber-release-intent | 40 ++- .../tests/test-workflow-puber-release-publish | 79 +++- .../tests/test-workflow-release-cleanup | 340 ++++++++++++------ .../tests/test-workflow-wait-github-release | 123 ++++++- .kent/scripts/workflow-puber-release-intent | 75 ++-- .kent/scripts/workflow-release-cleanup | 71 +++- .kent/scripts/workflow-wait-github-release | 7 +- .kent/workflows/puber-release.manifest.json | 14 +- 8 files changed, 579 insertions(+), 170 deletions(-) diff --git a/.kent/scripts/tests/test-workflow-puber-release-intent b/.kent/scripts/tests/test-workflow-puber-release-intent index 422d6085..37884a2e 100755 --- a/.kent/scripts/tests/test-workflow-puber-release-intent +++ b/.kent/scripts/tests/test-workflow-puber-release-intent @@ -97,4 +97,42 @@ with tempfile.TemporaryDirectory() as directory: ): result = subprocess.run([str(work / ".kent/scripts/workflow-puber-release-intent")], cwd=work, input=json.dumps(bad), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) assert result.returncode != 0 and result.stdout == "" -print("workflow-puber-release-intent: passed") + + def refs() -> str: + return subprocess.run(["git", "show-ref"], cwd=work, text=True, stdout=subprocess.PIPE, check=True).stdout + + def rejected_case(label: str, *, task_changes=None, payload_changes=None, source_drift: bool = False, env_override=None) -> None: + before = refs() + if task_changes: + changed = json.loads(task_file.read_text(encoding="utf-8")) + changed.update(task_changes) + task_file.write_text(json.dumps(changed), encoding="utf-8") + source = work / ".kent/scripts/workflow-puber-release-intent" + original = source.read_bytes() + if source_drift: + source.write_bytes(original + b"\n") + case = dict(success) + if payload_changes: + case.update(payload_changes) + case_env = env_override or env + result = subprocess.run([str(work / ".kent/scripts/workflow-puber-release-intent")], cwd=work, input=json.dumps(case), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=case_env) + if result.returncode == 0: + assert json.loads(result.stdout)["transition"] in {"release_intent_blocked", "release_intent_invalid"}, label + else: + assert result.stdout == "" and "error" in result.stderr, label + assert refs() == before, label + source.write_bytes(original) + task_file.write_text(json.dumps(task), encoding="utf-8") + + rejected_case("task_id", payload_changes={"_kent": {**success["_kent"], "task_id": "task-00000000-0000-4000-8000-000000000000"}}) + rejected_case("project", task_changes={"summary": {**task["summary"], "project_id": "foreign-project"}}) + rejected_case("workflow_revision", task_changes={"workflow": {**task["workflow"], "version": 87}}) + rejected_case("current_node", task_changes={"status": {"node_ids": ["foreign-node"]}}) + rejected_case("current_script", task_changes={"current_scripts": [{"path": ".kent/scripts/workflow-puber-release-publish", "current_node": {"node_id": node["id"]}}]}) + rejected_case("execution_commit", task_changes={"execution_target": {"commit_oid": "f" * 40}}) + rejected_case("worktree", task_changes={"worktree_path": str(root / "foreign")}) + rejected_case("source_drift", source_drift=True) + bad_kit = root / "bad-kit"; bad_kit.mkdir() + rejected_case("kit_drift", env_override={**env, "KENT_ENGINEERING_KIT_ROOT": str(bad_kit)}) + +print("workflow-puber-release-intent: passed scenarios=happy,malformed,task_id,project,workflow_revision,current_node,current_script,execution_commit,worktree,source_drift,kit_drift,no_effect") diff --git a/.kent/scripts/tests/test-workflow-puber-release-publish b/.kent/scripts/tests/test-workflow-puber-release-publish index a466decf..ee18a5ec 100755 --- a/.kent/scripts/tests/test-workflow-puber-release-publish +++ b/.kent/scripts/tests/test-workflow-puber-release-publish @@ -94,6 +94,7 @@ with tempfile.TemporaryDirectory() as directory: payload = {"_kent": {"task_id": task_id, "node_id": publish_node["id"], "transition_branch_key": "merge_watch_pr_merged"}, "workspace_path": str(work), "operation_id": "a"*64, "pr_url": "https://github.com/rovkinmax/Puber/pull/7", "branch_name": "release/99.0.0", "merge_strategy": "auto", "pr_head_oid": "c"*40, "pr_base_oid": "b"*40} env = {**os.environ, "KENT_ENGINEERING_KIT_ROOT": str(KIT), "PUBER_KENT_BIN": str(kent), "PUBER_GH_BIN": str(gh), "PUBER_FAKE_TASK": str(fake_task := root / "fake-task.json"), "PUBER_GH_LOG": str(log), "PUBER_TARGET": target, "PUBER_BARE": bare_path} fake_task.write_text(json.dumps(task), encoding="utf-8") + os.environ.update({key: env[key] for key in ("KENT_ENGINEERING_KIT_ROOT", "PUBER_KENT_BIN", "PUBER_GH_BIN", "PUBER_GH_LOG", "PUBER_TARGET", "PUBER_BARE")}) result = subprocess.run([str(work / ".kent/scripts/workflow-puber-release-publish")], cwd=work, input=json.dumps(payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) assert result.returncode == 0, result.stderr output = json.loads(result.stdout) @@ -154,4 +155,80 @@ with tempfile.TemporaryDirectory() as directory: pass else: raise AssertionError("conflicting canonical notes were accepted") -print("workflow-puber-release-publish: passed") + + local_only = "v99.0.2" + sh(["git", "tag", local_only, target], work) + assert helper.publish_tag(work, local_only, target) == "local_tag_pushed" + assert sh(["git", "--git-dir", bare_path, "rev-parse", f"refs/tags/{local_only}"], work).stdout.strip() == target + + lost_ack = "v99.0.3" + git_wrapper = root / "git" + real_git = shutil.which("git") + assert real_git + git_wrapper.write_text(f"#!/bin/sh\nif test \"$1\" = push; then '{real_git}' \"$@\"; exit 1; fi\nexec '{real_git}' \"$@\"\n", encoding="utf-8") + git_wrapper.chmod(0o755) + original_path = os.environ["PATH"] + os.environ["PATH"] = f"{root}:{original_path}" + lost_ack_status = helper.publish_tag(work, lost_ack, target) + assert lost_ack_status == "push_ack_recovered", lost_ack_status + os.environ["PATH"] = original_path + + strict_cases = [ + ("prepared_published", {"tag_phase": "prepared", "tag_status": "created_and_pushed", "unresolved": True}), + ("prepared_resolved", {"tag_phase": "prepared", "tag_status": "prepared", "unresolved": False}), + ("published_prepared", {"tag_phase": "tag_published", "tag_status": "prepared", "unresolved": True}), + ("published_unknown", {"tag_phase": "tag_published", "tag_status": "unknown", "unresolved": True}), + ] + for label, changes in strict_cases: + candidate = dict(report) + candidate.update(changes) + encoded = helper.canonical_json(candidate) + try: + helper.parse_publication_report(encoded, __import__("hashlib").sha256(encoded.encode()).hexdigest(), expected_tag="v99.0.0", expected_target=target, task_short_id="PUB-99", root=work) + except helper.ContractError: + pass + else: + raise AssertionError(label) + + def refs() -> tuple[str, str]: + local = sh(["git", "show-ref", "--tags"], work).stdout + remote = sh(["git", "--git-dir", bare_path, "show-ref", "--tags"], work).stdout + return local, remote + + def rejected_gate(label: str, *, task_changes=None, payload_changes=None, gate_env=None) -> None: + before = refs() + if task_changes: + changed_task = json.loads(fake_task.read_text(encoding="utf-8")) + changed_task.update(task_changes) + fake_task.write_text(json.dumps(changed_task), encoding="utf-8") + bad = dict(payload) + if payload_changes: + bad.update(payload_changes) + run_env = gate_env or env + result = subprocess.run([str(work / ".kent/scripts/workflow-puber-release-publish")], cwd=work, input=json.dumps(bad), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=run_env) + if result.returncode == 0: + assert json.loads(result.stdout)["transition"] == "publish_needs_user_action", label + else: + assert result.stdout == "" and "error" in result.stderr, label + assert refs() == before, label + fake_task.write_text(json.dumps(task), encoding="utf-8") + + rejected_gate("task_id", task_changes={"summary": {**task["summary"], "id": "task-00000000-0000-4000-8000-000000000000"}}) + rejected_gate("workflow_revision", task_changes={"workflow": {**task["workflow"], "version": 87}}) + rejected_gate("current_script", task_changes={"current_scripts": [{"path": ".kent/scripts/workflow-wait-github-release", "current_node": {"node_id": publish_node["id"]}}]}) + rejected_gate("execution_commit", task_changes={"execution_target": {"commit_oid": "f" * 40}}) + rejected_gate("worktree_path", task_changes={"worktree_path": str(root / "foreign")}) + rejected_gate("pr_identity", payload_changes={"pr_head_oid": "d" * 40}) + rejected_gate("transition", payload_changes={"_kent": {**payload["_kent"], "transition_branch_key": "not-a-real-edge"}}) + source = work / ".kent/scripts/workflow-puber-release-intent" + original_source = source.read_bytes() + source.write_bytes(original_source + b"\n") + rejected_gate("source_drift") + source.write_bytes(original_source) + bad_kit = root / "bad-kit"; bad_kit.mkdir() + bad_env = {**env, "KENT_ENGINEERING_KIT_ROOT": str(bad_kit)} + before = refs() + bad_result = subprocess.run([str(work / ".kent/scripts/workflow-puber-release-publish")], cwd=work, input=json.dumps(payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=bad_env) + assert bad_result.returncode == 0 and json.loads(bad_result.stdout)["transition"] == "publish_needs_user_action" and refs() == before + +print("workflow-puber-release-publish: passed scenarios=happy,retry,local_tag_only,lost_ack,conflicts,strict_phase_status,zero_effect_task_pr_kit_source_transition_gates") diff --git a/.kent/scripts/tests/test-workflow-release-cleanup b/.kent/scripts/tests/test-workflow-release-cleanup index 1ee68d37..ffa6392d 100755 --- a/.kent/scripts/tests/test-workflow-release-cleanup +++ b/.kent/scripts/tests/test-workflow-release-cleanup @@ -1,35 +1,63 @@ #!/usr/bin/env python3 from __future__ import annotations +import hashlib import importlib.util import json -from importlib.machinery import SourceFileLoader import os from pathlib import Path import shutil import subprocess import tempfile +from importlib.machinery import SourceFileLoader ROOT = Path(__file__).resolve().parents[3] KIT = Path("/Users/rovkinmax/.kent/worktrees/kent-engineering-kit/s02a-kit-profile") +cleanup_spec = importlib.util.spec_from_loader("cleanup_carrier_test", SourceFileLoader("cleanup_carrier_test", str(ROOT / ".kent/scripts/workflow-release-cleanup"))) +cleanup_mod = importlib.util.module_from_spec(cleanup_spec) +assert cleanup_spec and cleanup_spec.loader +cleanup_spec.loader.exec_module(cleanup_mod) -spec = importlib.util.spec_from_loader( - "cleanup_runtime_test", - SourceFileLoader("cleanup_runtime_test", str(ROOT / ".kent/scripts/workflow-release-cleanup")), -) -mod = importlib.util.module_from_spec(spec) -assert spec and spec.loader -spec.loader.exec_module(mod) def sh(args: list[str], cwd: Path, **kwargs: object) -> subprocess.CompletedProcess[str]: - return subprocess.run(args, cwd=cwd, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs) + return subprocess.run( + args, + cwd=cwd, + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + **kwargs, + ) + + +def expect_rejected(command: list[str], cwd: Path, payload: dict, env: dict[str, str], label: str) -> None: + result = subprocess.run( + command, + cwd=cwd, + input=json.dumps(payload), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + assert result.returncode != 0 and result.stdout == "" and "error" in result.stderr, label + with tempfile.TemporaryDirectory() as directory: root = Path(directory) - work = root / "work" - sh(["git", "clone", "--quiet", str(ROOT), str(work)], root) + primary = root / "home" / "dev" / "android" / "Puber" + managed = root / "home" / ".kent" / "worktrees" / "PUB-99" + bare = root / "origin.git" + primary.parent.mkdir(parents=True) + sh(["git", "clone", "--quiet", str(ROOT), str(primary)], root) + sh(["git", "init", "--bare", str(bare)], root) + sh(["git", "remote", "set-url", "origin", str(bare)], primary) + managed.parent.mkdir(parents=True) + sh(["git", "worktree", "add", "--quiet", "-b", "release/99.0.0", str(managed), "HEAD"], primary) + manifest = json.loads((ROOT / ".kent/workflows/puber-release.manifest.json").read_text(encoding="utf-8")) - for relative in sorted(set(manifest["additional_paths"]) | { + copied = set(manifest["additional_paths"]) | { ".kent/scripts/workflow-puber-release-intent", ".kent/scripts/workflow-evidence-ledger", ".kent/scripts/workflow-release-cleanup", @@ -39,44 +67,55 @@ with tempfile.TemporaryDirectory() as directory: ".kent/workflows/puber-release.manifest.json", ".kent/workflows/specs/puber-release.toml", ".kent/workflows/builders/puber_release.py", - }): - source = ROOT / relative - if relative == ".kent/scripts/workflow-task-janitor": - source = KIT / "templates/project/workflow-task-janitor" - target_path = work / relative - target_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, target_path) - sh(["git", "config", "user.email", "test@example.invalid"], work) - sh(["git", "config", "user.name", "Puber Test"], work) - sh(["git", "add", "."], work) - if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=work).returncode != 0: - sh(["git", "commit", "--quiet", "-m", "cleanup test fixture"], work) - evidence = { - "node_key": "cleanup", - "evidence_type": "cleanup_fixture", - "summary": "Cleanup fixture entered with an ordinary evidence record.", - "artifacts": ["cleanup-fixture"], - "checks": ["fixture"], - "decisions": ["report_only"], - "context": { - "manifest_path": ".kent/context/implement.md", - "files_read": [".kent/project-contract.md"], - "model_calls": 0, - "compaction_count": 0, - "repeated_questions": 0, - "verification_loops": 0, - }, } + for relative in sorted(copied): + source = KIT / "templates/project/workflow-task-janitor" if relative == ".kent/scripts/workflow-task-janitor" else ROOT / relative + target = managed / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + carrier_notes_path = managed / ".todo/PUB-99/release-notes-ru.md" + carrier_notes_path.parent.mkdir(parents=True, exist_ok=True) + carrier_body = "puber_release_notes_v1\n\n# Изменения\n\n- Проверка релиза.\n".encode("utf-8") + carrier_notes_path.write_bytes(carrier_body) + carrier_notes_path.chmod(0o600) + carrier_notes_path = managed / ".todo/PUB-99/release-notes-ru.md" + carrier_notes_path.parent.mkdir(parents=True, exist_ok=True) + carrier_body = "puber_release_notes_v1\n\n# Изменения\n\n- Проверка релиза.\n".encode("utf-8") + carrier_notes_path.write_bytes(carrier_body) + carrier_notes_path.chmod(0o600) + sh(["git", "config", "user.email", "test@example.invalid"], managed) + sh(["git", "config", "user.name", "Puber Test"], managed) + sh(["git", "add", "."], managed) + if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=managed).returncode != 0: + sh(["git", "commit", "--quiet", "-m", "cleanup test fixture"], managed) + target = sh(["git", "rev-parse", "HEAD"], managed).stdout.strip() + sh(["git", "push", "--quiet", "-u", "origin", "release/99.0.0"], managed) sh( - [str(work / ".kent/scripts/workflow-evidence-ledger"), "append", "--task", "PUB-99", "--workspace", str(work)], - work, - input=json.dumps(evidence), + [str(managed / ".kent/scripts/workflow-evidence-ledger"), "append", "--task", "PUB-99", "--workspace", str(managed)], + managed, + input=json.dumps({ + "node_key": "cleanup", + "evidence_type": "cleanup_fixture", + "summary": "Cleanup fixture entered with an ordinary evidence record.", + "artifacts": ["cleanup-fixture"], + "checks": ["fixture"], + "decisions": ["report_only"], + "context": { + "manifest_path": ".kent/context/implement.md", + "files_read": [".kent/project-contract.md"], + "model_calls": 0, + "compaction_count": 0, + "repeated_questions": 0, + "verification_loops": 0, + }, + }), ) - target = sh(["git", "rev-parse", "HEAD"], work).stdout.strip() - graph = json.loads((work / ".kent/workflows/puber-release.json").read_text(encoding="utf-8")) + + graph = json.loads((managed / ".kent/workflows/puber-release.json").read_text(encoding="utf-8")) cleanup_node = next(node for node in graph["nodes"] if node["key"] == "cleanup") task_id = "task-12345678-1234-4234-8234-123456789abc" session_id = "session-cleanup-1" + workspace_id = "workspace-fixture-1234" task = { "actions": [], "attention_count": 0, @@ -92,7 +131,7 @@ with tempfile.TemporaryDirectory() as directory: "status": {"node_ids": [cleanup_node["id"]]}, "summary": {"id": task_id, "project_id": "project-6c1b6c4e-c8e2-4170-b9f7-a7d9c4602176", "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "short_id": "PUB-99"}, "workflow": {"workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "version": 88, "name": "Puber Release"}, - "worktree_path": str(work), + "worktree_path": str(managed), } fake = root / "fake" fake.mkdir() @@ -101,117 +140,184 @@ with tempfile.TemporaryDirectory() as directory: kent = fake / "kent" kent.write_text("#!/bin/sh\ncat \"$PUBER_FAKE_TASK\"\n", encoding="utf-8") kent.chmod(0o755) - wrapper = fake / "kent-worktree" moved = fake / "moved" + wrapper = fake / "kent-worktree" wrapper.write_text( "#!/bin/sh\n" - "test \"$1\" = status && test \"$2\" = --session && test \"$4\" = --json\n" - f"if test -f '{moved}'; then effective='/tmp/pub-99-moved'; else effective='{work}'; fi\n" - f"printf '{{\"target\":{{\"EffectiveWorkdir\":\"%s\"}},\"worktree\":{{\"recorded_root\":\"{work}\",\"observed_root\":\"{work}\"}}}}\\n' \"$effective\"\n", + "if test \"$1\" = status; then\n" + " test \"$2\" = --session && test \"$4\" = --json || exit 2\n" + f" if test -f '{moved}'; then\n" + f" printf '%s\\n' '{{\"target\":{{\"WorkspaceID\":\"{workspace_id}\",\"WorkspaceName\":\"Puber\",\"WorkspaceRoot\":\"{primary}\",\"WorkspaceAvailability\":\"available\",\"Worktree\":null,\"CwdRelpath\":\".\",\"EffectiveWorkdir\":\"{primary}\"}},\"worktree\":{{\"recorded_root\":\"{primary}\",\"observed_root\":\"{primary}\"}},\"problems\":[]}}'\n" + " else\n" + f" printf '%s\\n' '{{\"target\":{{\"WorkspaceID\":\"{workspace_id}\",\"WorkspaceName\":\"Puber\",\"WorkspaceRoot\":\"{primary}\",\"WorkspaceAvailability\":\"available\",\"Worktree\":{{\"ID\":\"worktree-fixture\",\"Name\":\"PUB-99\",\"Root\":\"{managed}\",\"Availability\":\"available\"}},\"CwdRelpath\":\".\",\"EffectiveWorkdir\":\"{managed}\"}},\"worktree\":{{\"recorded_root\":\"{managed}\",\"observed_root\":\"{managed}\"}},\"problems\":[]}}'\n" + " fi\n" + " exit 0\n" + "fi\n" + "if test \"$1\" = delete; then\n" + " printf '%s\\n' '{\"kind\":\"scheduled\",\"scheduled\":{\"operation_id\":\"delete-fixture\"}}'\n" + " exit 0\n" + "fi\n" + "exit 2\n", encoding="utf-8", ) wrapper.chmod(0o755) env = { **os.environ, + "HOME": str(root / "home"), "PYTHONDONTWRITEBYTECODE": "1", + "PUBER_TEST_MODE": "1", + "PUBER_TEST_PRIMARY_WORKSPACE_ID": workspace_id, + "PUBER_TEST_PRIMARY_WORKSPACE_ROOT": str(primary), "KENT_ENGINEERING_KIT_ROOT": str(KIT), "PUBER_KENT_BIN": str(kent), "PUBER_KENT_WORKTREE_BIN": str(wrapper), + "KENT_WORKTREE_WRAPPER": str(wrapper), "PUBER_FAKE_TASK": str(task_file), } cancel = { "_kent": {"task_id": task_id, "node_id": cleanup_node["id"], "transition_branch_key": "release_cancel_cleanup"}, "pr_url": "", "branch_name": "release/99.0.0", - "workspace_path": str(work), + "workspace_path": str(managed), "cleanup_reason": "cancelled", } - result = subprocess.run( - [str(work / ".kent/scripts/workflow-release-cleanup")], - cwd=work, - input=json.dumps(cancel), + + initial = subprocess.run([str(managed / ".kent/scripts/workflow-release-cleanup")], cwd=managed, input=json.dumps(cancel), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert initial.returncode == 0, initial.stderr + initial_output = json.loads(initial.stdout) + assert initial_output["transition"] == "cleanup_task_janitor" and initial_output["cleanup_session_id"] == session_id + initial_report = initial_output["cleanup_report"] + initial_lines = initial_report.splitlines() + assert len(initial_lines) == 2 and initial_lines[1].startswith("TERMINAL_EVIDENCE_V1 ") + initial_json = json.loads(initial_lines[0]) + marker = json.loads(initial_lines[1].removeprefix("TERMINAL_EVIDENCE_V1 ")) + assert marker["redaction"]["report_sha256"] == hashlib.sha256(initial_lines[0].encode()).hexdigest() + ledger = managed / ".kent/runtime/PUB-99/evidence-ledger.jsonl" + assert marker["final_hash"] == json.loads(ledger.read_text().splitlines()[-1])["event_hash"] + + cleanup_mod.ROOT = managed + carrier_notes = {"schema": "puber_release_notes_v1", "path": ".todo/PUB-99/release-notes-ru.md", "mode": 0o600, "bytes": len(carrier_body), "sha256": hashlib.sha256(carrier_body).hexdigest(), "encoding": "UTF-8", "line_endings": "LF", "final_newline": True, "producer": ".kent/scripts/workflow-puber-release-publish", "previous_tag": "", "range": target, "target_commit": target} + publication, publication_digest = cleanup_mod.helper.publication_report("b" * 64, "https://github.com/rovkinmax/Puber/pull/7", "99.0.0", "v99.0.0", target, carrier_notes, tag_phase="tag_published", tag_status="already_present_exact", unresolved=True) + apk_name = "puber-v99.0.0.apk" + checksum_name = apk_name + ".sha256" + assets = [{"id": 1, "name": apk_name, "size": 4, "browser_download_url": "https://example.invalid/apk", "download_sha256": "a" * 64}, {"id": 2, "name": checksum_name, "size": 70, "browser_download_url": "https://example.invalid/checksum", "download_sha256": "b" * 64}] + release_value = {"schema": "puber_release_report_v1", "observation_operation_digest": "c" * 64, "observation_provenance_fingerprint": "d" * 64, "run": {"schema": "puber_release_run_v1", "state": "selected", "repository": "rovkinmax/Puber", "workflow_path": ".github/workflows/release.yml", "workflow_name": "Release", "event": "push", "run_id": 42, "attempt": 1, "head_sha": target, "ref": "refs/tags/v99.0.0", "head_branch": "v99.0.0"}, "jobs": {"run": {"run_id": 42, "attempt": 1, "path": ".github/workflows/release.yml", "name": "Release", "event": "push", "head_sha": target, "head_branch": "v99.0.0", "status": "completed", "conclusion": "success"}, "job": {"id": 777, "name": "Build & Publish", "status": "completed", "conclusion": "success", "attempt": 1}}, "release": {"tag_name": "v99.0.0", "target_commitish": "master", "name": "Puber v99.0.0", "draft": False, "prerelease": False, "html_url": "https://github.com/rovkinmax/Puber/releases/tag/v99.0.0", "body": carrier_body.decode(), "remote_tag_target": target}, "assets": assets, "checksum": {"schema": "puber_checksum_proof_v1", "apk_asset_id": 1, "apk_name": apk_name, "apk_size": 4, "apk_download_sha256": "a" * 64, "checksum_asset_id": 2, "checksum_name": checksum_name, "checksum_size": 70, "checksum_download_sha256": "b" * 64, "parsed_checksum_digest": "a" * 64, "parsed_checksum_filename": apk_name, "download_sizes_match": True, "apk_checksum_equal": True}, "notes_digest": carrier_notes["sha256"], "publication_report_digest": publication_digest, "unresolved": False} + release_report = cleanup_mod.helper.canonical_json(release_value) + release_digest = hashlib.sha256(release_report.encode()).hexdigest() + valid_carrier = {"publication_report": publication, "publication_report_digest": publication_digest, "release_report": release_report, "release_report_digest": release_digest, "release_notes_path": carrier_notes["path"], "release_notes_digest": carrier_notes["sha256"]} + assert cleanup_mod._validate_carriers({"task_short_id": "PUB-99"}, valid_carrier)[0] is not None + broken_carrier = dict(valid_carrier) + broken_value = json.loads(release_report) + broken_value["run"]["head_sha"] = "e" * 40 + broken_carrier["release_report"] = cleanup_mod.helper.canonical_json(broken_value) + broken_carrier["release_report_digest"] = hashlib.sha256(broken_carrier["release_report"].encode()).hexdigest() + try: + cleanup_mod._validate_carriers({"task_short_id": "PUB-99"}, broken_carrier) + except cleanup_mod.helper.ContractError: + pass + else: + raise AssertionError("cleanup selected-run carrier mismatch accepted") + + notes_path = managed / ".todo/PUB-99/release-notes-ru.md" + notes_path.parent.mkdir(parents=True, exist_ok=True) + notes_body = "puber_release_notes_v1\n\n# Изменения\n\n- Проверка релиза.\n".encode("utf-8") + notes_path.write_bytes(notes_body) + notes_path.chmod(0o600) + notes = {"schema": "puber_release_notes_v1", "path": ".todo/PUB-99/release-notes-ru.md", "mode": 0o600, "bytes": len(notes_body), "sha256": hashlib.sha256(notes_body).hexdigest(), "encoding": "UTF-8", "line_endings": "LF", "final_newline": True, "producer": ".kent/scripts/workflow-puber-release-publish", "previous_tag": "", "range": target, "target_commit": target} + publication, publication_digest = cleanup_mod.helper.publication_report("b" * 64, "https://github.com/rovkinmax/Puber/pull/7", "99.0.0", "v99.0.0", target, notes, tag_phase="tag_published", tag_status="already_present_exact", unresolved=True) + apk_name = "puber-v99.0.0.apk" + checksum_name = apk_name + ".sha256" + assets = [{"id": 1, "name": apk_name, "size": 4, "browser_download_url": "https://example.invalid/apk", "download_sha256": "a" * 64}, {"id": 2, "name": checksum_name, "size": 70, "browser_download_url": "https://example.invalid/checksum", "download_sha256": "b" * 64}] + release_value = {"schema": "puber_release_report_v1", "observation_operation_digest": "c" * 64, "observation_provenance_fingerprint": "d" * 64, "run": {"schema": "puber_release_run_v1", "state": "selected", "repository": "rovkinmax/Puber", "workflow_path": ".github/workflows/release.yml", "workflow_name": "Release", "event": "push", "run_id": 42, "attempt": 1, "head_sha": target, "ref": "refs/tags/v99.0.0", "head_branch": "v99.0.0"}, "jobs": {"run": {"run_id": 42, "attempt": 1, "path": ".github/workflows/release.yml", "name": "Release", "event": "push", "head_sha": target, "head_branch": "v99.0.0", "status": "completed", "conclusion": "success"}, "job": {"id": 777, "name": "Build & Publish", "status": "completed", "conclusion": "success", "attempt": 1}}, "release": {"tag_name": "v99.0.0", "target_commitish": "master", "name": "Puber v99.0.0", "draft": False, "prerelease": False, "html_url": "https://github.com/rovkinmax/Puber/releases/tag/v99.0.0", "body": notes_body.decode(), "remote_tag_target": target}, "assets": assets, "checksum": {"schema": "puber_checksum_proof_v1", "apk_asset_id": 1, "apk_name": apk_name, "apk_size": 4, "apk_download_sha256": "a" * 64, "checksum_asset_id": 2, "checksum_name": checksum_name, "checksum_size": 70, "checksum_download_sha256": "b" * 64, "parsed_checksum_digest": "a" * 64, "parsed_checksum_filename": apk_name, "download_sizes_match": True, "apk_checksum_equal": True}, "notes_digest": notes["sha256"], "publication_report_digest": publication_digest, "unresolved": False} + release_report = cleanup_mod.helper.canonical_json(release_value) + release_digest = hashlib.sha256(release_report.encode()).hexdigest() + cleanup_mod.ROOT = managed + valid_carrier = {"publication_report": publication, "publication_report_digest": publication_digest, "release_report": release_report, "release_report_digest": release_digest, "release_notes_path": notes["path"], "release_notes_digest": notes["sha256"]} + assert cleanup_mod._validate_carriers({"task_short_id": "PUB-99"}, valid_carrier)[0] is not None + broken_carrier = dict(valid_carrier) + broken_value = json.loads(release_report) + broken_value["run"]["head_sha"] = "e" * 40 + broken_carrier["release_report"] = cleanup_mod.helper.canonical_json(broken_value) + broken_carrier["release_report_digest"] = hashlib.sha256(broken_carrier["release_report"].encode()).hexdigest() + try: + cleanup_mod._validate_carriers({"task_short_id": "PUB-99"}, broken_carrier) + except cleanup_mod.helper.ContractError: + pass + else: + raise AssertionError("cleanup selected-run carrier mismatch accepted") + conflict_seal = subprocess.run( + [str(managed / ".kent/scripts/workflow-evidence-ledger"), "seal", "--task", "PUB-99", "--workspace", str(managed)], + cwd=managed, + input=json.dumps({"schema": "terminal-evidence-seal-request-v1", "operation_report_digests": marker["operation_report_digests"], "redaction": {"status": "passed", "report_sha256": "0" * 64}, "retention_class": "cleanup_report_only"}), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, ) - assert result.returncode == 0, result.stderr + "\n" + subprocess.run(["git", "status", "--porcelain=v1", "--untracked-files=all"], cwd=work, text=True, stdout=subprocess.PIPE).stdout - output = json.loads(result.stdout) - assert output["transition"] == "cleanup_task_janitor" - assert output["cleanup_session_id"] == session_id - report_lines = output["cleanup_report"].splitlines() - assert len(report_lines) == 2 and report_lines[1].startswith("TERMINAL_EVIDENCE_V1 ") - report = json.loads(report_lines[0]) - assert report["operation_digest"] != "" - assert report["destructive_action"] is False and report["unresolved"] is False + assert conflict_seal.returncode != 0 and "conflicts" in conflict_seal.stderr - task["live_sessions"] = [] - task_file.write_text(json.dumps(task), encoding="utf-8") - moved.touch() - janitor = subprocess.run( - [str(work / ".kent/scripts/workflow-task-janitor")], - cwd=work, - input=json.dumps( - { - "workspace_path": str(work), - "branch_name": "", - "pr_url": "", - "cleanup_mode": "report_only", - "cleanup_session_id": session_id, - "cleanup_report": output["cleanup_report"], - "task_short_id": "PUB-99", - } - ), + first_janitor = subprocess.run( + [str(managed / ".kent/scripts/workflow-task-janitor")], + cwd=managed, + input=json.dumps({"workspace_path": str(managed), "branch_name": "release/99.0.0", "pr_url": "", "cleanup_mode": "report_only", "cleanup_session_id": session_id, "cleanup_report": initial_report, "task_short_id": "PUB-99"}), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, ) - assert janitor.returncode == 0, janitor.stderr - janitor_output = json.loads(janitor.stdout) - assert janitor_output["cleanup_report"].splitlines()[1] == report_lines[1] - blocked = { + assert first_janitor.returncode == 0, first_janitor.stderr + first_output = json.loads(first_janitor.stdout) + assert first_output["transition"] == "task_janitor_blocked" + assert "kent worktree leave" in first_output["blocker_reason"] + assert first_output["cleanup_report"].splitlines()[:2] == initial_lines + assert managed.exists() and (primary / ".git").exists() + + task["live_sessions"] = [] + task_file.write_text(json.dumps(task), encoding="utf-8") + moved.touch() + retry = { "_kent": {"task_id": task_id, "node_id": cleanup_node["id"], "transition_branch_key": "task_janitor_blocked"}, - "workspace_path": str(work), + "workspace_path": str(managed), "operation_id": "a" * 64, "branch_name": "release/99.0.0", "cleanup_mode": "report_only", - "cleanup_report": janitor_output["cleanup_report"], + "cleanup_report": first_output["cleanup_report"], } - result = subprocess.run( - [str(work / ".kent/scripts/workflow-release-cleanup")], - cwd=work, - input=json.dumps(blocked), - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=env, - ) - assert result.returncode == 0, result.stderr + "\n" + subprocess.run(["git", "status", "--porcelain=v1", "--untracked-files=all"], cwd=work, text=True, stdout=subprocess.PIPE).stdout - retry = json.loads(result.stdout) - retry_lines = retry["cleanup_report"].splitlines() - retry_report = json.loads(retry_lines[0]) - assert retry["transition"] == "cleanup_task_janitor" - assert retry["cleanup_session_id"] == session_id - assert retry_report["unresolved"] is True - assert retry_report["blocker_reason"].startswith("Janitor ") - assert retry_report["operation_digest"] != "a" * 64 - assert retry_lines[1] == report_lines[1] - - invalid = dict(cancel) - invalid["cleanup_reason"] = "" - invalid["branch_name"] = "not-a-release-branch" - result = subprocess.run( - [str(work / ".kent/scripts/workflow-release-cleanup")], - cwd=work, - input=json.dumps(invalid), + retry_result = subprocess.run([str(managed / ".kent/scripts/workflow-release-cleanup")], cwd=managed, input=json.dumps(retry), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert retry_result.returncode == 0, retry_result.stderr + retry_output = json.loads(retry_result.stdout) + assert retry_output["cleanup_session_id"] == session_id + assert retry_output["cleanup_report"] != first_output["cleanup_report"] + retry_lines = retry_output["cleanup_report"].splitlines() + assert retry_lines[0] == initial_lines[0] and retry_lines[-1] == initial_lines[1] + assert json.loads(retry_output["cleanup_report"].splitlines()[0]) == initial_json + + second_janitor = subprocess.run( + [str(managed / ".kent/scripts/workflow-task-janitor")], + cwd=managed, + input=json.dumps({"workspace_path": str(managed), "branch_name": "release/99.0.0", "pr_url": "", "cleanup_mode": "report_only", "cleanup_session_id": session_id, "cleanup_report": retry_output["cleanup_report"], "task_short_id": "PUB-99"}), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, ) - assert result.returncode != 0 - assert result.stdout == "" - assert "error" in result.stderr + assert second_janitor.returncode == 0, second_janitor.stderr + second_output = json.loads(second_janitor.stdout) + assert second_output["transition"] == "task_janitor_blocked" + assert "still targets the task worktree" not in second_output.get("blocker_reason", "") + assert "scheduled worktree deletion" in second_output["cleanup_report"], (second_output["blocker_reason"], second_output["cleanup_report"].splitlines()[-1]) + + tampered = initial_lines[0][:-1] + "0\n" + initial_lines[1] + "\nJanitor tampered" + expect_rejected([str(managed / ".kent/scripts/workflow-release-cleanup")], managed, {**retry, "cleanup_report": tampered}, env, "tampered canonical line") + expect_rejected([str(managed / ".kent/scripts/workflow-release-cleanup")], managed, {**retry, "cleanup_report": initial_lines[0] + "\n" + initial_lines[1][:-1] + "0\nJanitor tampered"}, env, "tampered marker") + marker_task = json.loads(initial_lines[1].removeprefix("TERMINAL_EVIDENCE_V1 ")) + marker_task["task_short_id"] = "PUB-100" + marker_task_line = "TERMINAL_EVIDENCE_V1 " + json.dumps(marker_task, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + expect_rejected([str(managed / ".kent/scripts/workflow-release-cleanup")], managed, {**retry, "cleanup_report": initial_lines[0] + "\n" + marker_task_line + "\nJanitor tampered"}, env, "tampered marker task") + marker_hash = json.loads(initial_lines[1].removeprefix("TERMINAL_EVIDENCE_V1 ")) + marker_hash["final_hash"] = "0" * 64 + marker_hash_line = "TERMINAL_EVIDENCE_V1 " + json.dumps(marker_hash, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + expect_rejected([str(managed / ".kent/scripts/workflow-release-cleanup")], managed, {**retry, "cleanup_report": initial_lines[0] + "\n" + marker_hash_line + "\nJanitor tampered"}, env, "tampered marker hash") + expect_rejected([str(managed / ".kent/scripts/workflow-release-cleanup")], managed, {**retry, "cleanup_report": initial_report + "\nTERMINAL_EVIDENCE_V1 extra"}, env, "extra terminal marker") -print("workflow-release-cleanup: passed") +print("workflow-release-cleanup: passed scenarios=initial_seal,release_carrier_success,close_without_merge_entry,janitor_block,leave_retry,janitor_later_boundary,tampered_line,marker,marker_task,marker_hash,extra_marker") diff --git a/.kent/scripts/tests/test-workflow-wait-github-release b/.kent/scripts/tests/test-workflow-wait-github-release index cff50d43..eb538479 100755 --- a/.kent/scripts/tests/test-workflow-wait-github-release +++ b/.kent/scripts/tests/test-workflow-wait-github-release @@ -1,6 +1,7 @@ #!/usr/bin/env python3 from __future__ import annotations +import hashlib import importlib.util import json from importlib.machinery import SourceFileLoader @@ -13,6 +14,9 @@ ROOT = Path(__file__).resolve().parents[3] publish_spec = importlib.util.spec_from_loader("publish_fixture", SourceFileLoader("publish_fixture", str(ROOT / ".kent/scripts/tests/test-workflow-puber-release-publish"))) publish_fixture = importlib.util.module_from_spec(publish_spec); assert publish_spec and publish_spec.loader; publish_spec.loader.exec_module(publish_fixture) +def sh(args, cwd, **kwargs): + return subprocess.run(args, cwd=cwd, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs) + with tempfile.TemporaryDirectory() as directory: root = Path(directory); bare = root / "origin.git" work, target, bare_path = publish_fixture.materialize(root, bare) @@ -31,15 +35,19 @@ with tempfile.TemporaryDirectory() as directory: task_path = fake / "task.json"; task_path.write_text(json.dumps(task), encoding="utf-8") kent = fake / "kent"; kent.write_text("#!/bin/sh\ncat \"$PUBER_FAKE_TASK\"\n", encoding="utf-8"); kent.chmod(0o755) body_file = fake / "body"; body_file.write_text("", encoding="utf-8") + calls_file = fake / "calls" apk_file = fake / "apk"; apk_file.write_bytes(b"test-apk-bytes") checksum_file = fake / "checksum"; checksum_file.write_text(__import__("hashlib").sha256(apk_file.read_bytes()).hexdigest() + " puber-v99.0.0.apk\n", encoding="utf-8") gh = fake / "gh" gh.write_text( """#!/usr/bin/env python3 import json,os,subprocess,sys -args=sys.argv[1:]; target=os.environ["PUBER_TARGET"]; tag="v99.0.0" +args=sys.argv[1:]; target=os.environ["PUBER_TARGET"]; tag="v99.0.0"; mode=os.environ.get("PUBER_MODE","normal") +with open(os.environ["PUBER_CALLS"], "a", encoding="utf-8") as calls: calls.write(json.dumps(args)+"\\n") if args[:2] == ["release","edit"]: - open(os.environ["PUBER_BODY"],"w",encoding="utf-8").write(sys.stdin.read()); sys.exit(0) + body=sys.stdin.read() + if mode != "edit_mismatch": open(os.environ["PUBER_BODY"],"w",encoding="utf-8").write(body) + sys.exit(1 if mode == "edit_lost_ack" else 0) if args[0] != "api": sys.exit(2) endpoint=args[1] if endpoint.endswith("/git/ref/heads/master"): @@ -51,10 +59,23 @@ if "/git/ref/tags/" in endpoint: except subprocess.CalledProcessError: print("404",file=sys.stderr); sys.exit(1) print(json.dumps({"ref":"refs/tags/"+tag,"node_id":"tag","url":"https://api.github.com/ref","object":{"type":"commit","sha":sha}})); sys.exit(0) if "/actions/runs?" in endpoint: + if mode == "no_discovery": sys.exit(9) + if mode == "foreign": + print(json.dumps({"workflow_runs":[{"id":42,"run_attempt":1,"path":".github/workflows/release.yml","name":"Release","event":"push","head_sha":target,"head_branch":tag,"repository":{"full_name":"foreign/Puber"}}]})); sys.exit(0) + if mode == "extra_run": + rows=[{"id":42,"run_attempt":1,"path":".github/workflows/release.yml","name":"Release","event":"push","head_sha":target,"head_branch":tag,"repository":{"full_name":"rovkinmax/Puber"}},{"id":43,"run_attempt":1,"path":".github/workflows/release.yml","name":"Release","event":"push","head_sha":target,"head_branch":tag,"repository":{"full_name":"rovkinmax/Puber"}}] + print(json.dumps({"workflow_runs":rows})); sys.exit(0) + if mode == "missing_run": + print(json.dumps({"workflow_runs":[]})); sys.exit(0) print(json.dumps({"workflow_runs":[{"id":42,"run_attempt":1,"path":".github/workflows/release.yml","name":"Release","event":"push","head_sha":target,"head_branch":tag,"repository":{"full_name":"rovkinmax/Puber"}}]})); sys.exit(0) if "/actions/runs/42/attempts/1/jobs" in endpoint: + if mode == "extra_job": print(json.dumps({"jobs":[{"id":777,"name":"Build & Publish","status":"completed","conclusion":"success"},{"id":778,"name":"extra","status":"completed","conclusion":"success"}]})); sys.exit(0) + if mode == "missing_job": print(json.dumps({"jobs":[]})); sys.exit(0) + if mode == "failed_job": print(json.dumps({"jobs":[{"id":777,"name":"Build & Publish","status":"completed","conclusion":"failure"}]})); sys.exit(0) print(json.dumps({"jobs":[{"id":777,"name":"Build & Publish","status":"completed","conclusion":"success"}]})); sys.exit(0) if "/actions/runs/42/attempts/1" in endpoint: + if mode == "block": sys.exit(9) + if mode == "failed_run": print(json.dumps({"id":42,"run_attempt":1,"path":".github/workflows/release.yml","name":"Release","event":"push","head_sha":target,"head_branch":tag,"repository":{"full_name":"rovkinmax/Puber"},"status":"completed","conclusion":"failure"})); sys.exit(0) print(json.dumps({"id":42,"run_attempt":1,"path":".github/workflows/release.yml","name":"Release","event":"push","head_sha":target,"head_branch":tag,"repository":{"full_name":"rovkinmax/Puber"},"status":"completed","conclusion":"success"})); sys.exit(0) if "/releases/assets/1" in endpoint: sys.stdout.buffer.write(open(os.environ["PUBER_APK"],"rb").read()); sys.exit(0) @@ -67,9 +88,15 @@ sys.exit(3) """, encoding="utf-8", ); gh.chmod(0o755) - env = {**os.environ, "KENT_ENGINEERING_KIT_ROOT": str(publish_fixture.KIT), "PUBER_KENT_BIN": str(kent), "PUBER_GH_BIN": str(gh), "PUBER_FAKE_TASK": str(task_path), "PUBER_TARGET": target, "PUBER_BARE": bare_path, "PUBER_BODY": str(body_file), "PUBER_APK": str(apk_file), "PUBER_CHECKSUM": str(checksum_file)} + env = {**os.environ, "KENT_ENGINEERING_KIT_ROOT": str(publish_fixture.KIT), "PUBER_KENT_BIN": str(kent), "PUBER_GH_BIN": str(gh), "PUBER_FAKE_TASK": str(task_path), "PUBER_TARGET": target, "PUBER_BARE": bare_path, "PUBER_BODY": str(body_file), "PUBER_APK": str(apk_file), "PUBER_CHECKSUM": str(checksum_file), "PUBER_CALLS": str(calls_file)} + os.environ.update({key: env[key] for key in ("KENT_ENGINEERING_KIT_ROOT", "PUBER_KENT_BIN", "PUBER_GH_BIN", "PUBER_FAKE_TASK", "PUBER_TARGET", "PUBER_BARE", "PUBER_BODY", "PUBER_APK", "PUBER_CHECKSUM", "PUBER_CALLS")}) + probe = subprocess.run([str(gh), "api", "repos/rovkinmax/Puber/pulls/7"], cwd=work, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert probe.returncode == 0, probe.stderr publish_payload = {"_kent":{"task_id":task_id,"node_id":publish_node["id"],"transition_branch_key":"merge_watch_pr_merged"},"workspace_path":str(work),"operation_id":"a"*64,"pr_url":"https://github.com/rovkinmax/Puber/pull/7","branch_name":"release/99.0.0","merge_strategy":"auto","pr_head_oid":"c"*40,"pr_base_oid":"b"*40} - published = json.loads(subprocess.run([str(work/".kent/scripts/workflow-puber-release-publish")], cwd=work, input=json.dumps(publish_payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env).stdout) + published_result = subprocess.run([str(work/".kent/scripts/workflow-puber-release-publish")], cwd=work, input=json.dumps(publish_payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert published_result.returncode == 0, published_result.stderr + published = json.loads(published_result.stdout) + assert published["transition"] == "monitor_release", (published, published_result.stderr) task.update({"current_nodes":[{"node_id":monitor_node["id"]}],"current_scripts":[{"path":".kent/scripts/workflow-wait-github-release","current_node":{"node_id":monitor_node["id"]}}],"status":{"node_ids":[monitor_node["id"]]}}) task_path.write_text(json.dumps(task), encoding="utf-8") monitor_payload = {"_kent":{"task_id":task_id,"node_id":monitor_node["id"],"transition_branch_key":"monitor_release"}} @@ -77,7 +104,7 @@ sys.exit(3) result = subprocess.run([str(work/".kent/scripts/workflow-wait-github-release")], cwd=work, input=json.dumps(monitor_payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) assert result.returncode == 0, result.stderr output = json.loads(result.stdout) - assert output["transition"] == "release_release_published" + assert output["transition"] == "release_release_published", output assert json.loads(output["publication_report"])["unresolved"] is False release_report = json.loads(output["release_report"]) assert release_report["unresolved"] is False @@ -88,4 +115,88 @@ sys.exit(3) assert release_report["checksum"]["checksum_asset_id"] == 2 assert release_report["checksum"]["apk_checksum_equal"] is True assert body_file.read_text(encoding="utf-8").startswith("puber_release_notes_v1") -print("workflow-wait-github-release: passed") + + def rejected(label: str, action) -> None: + try: + action() + except publish_fixture.helper.ContractError: + return + raise AssertionError(label) + + selected_run = publish_fixture.helper.parse_run_carrier(output["release_run"]) + for field, value in (("head_sha", "b" * 40), ("head_branch", "v99.0.4"), ("ref", "refs/tags/v99.0.4")): + candidate = json.loads(json.dumps(release_report)) + candidate["run"][field] = value + encoded = publish_fixture.helper.canonical_json(candidate) + rejected(f"selected run {field} mismatch", lambda encoded=encoded, candidate=candidate: publish_fixture.helper.parse_release_report(encoded, hashlib.sha256(encoded.encode()).hexdigest(), expected_publication_digest=candidate["publication_report_digest"], expected_notes_digest=candidate["notes_digest"], expected_tag="v99.0.0", expected_target=target)) + + for mode in ("foreign", "extra_run", "missing_run"): + os.environ["PUBER_MODE"] = mode + rejected(f"discovery {mode}", lambda mode=mode: publish_fixture.helper.discover_release_run(work, "v99.0.0", target)) + for mode in ("failed_run", "extra_job", "missing_job", "failed_job"): + os.environ["PUBER_MODE"] = mode + rejected(f"readback {mode}", lambda mode=mode: publish_fixture.helper.read_release_run(work, selected_run, target, "v99.0.0")) + + wrong = sh(["git", "rev-parse", f"{target}^"], work).stdout.strip() + sh(["git", "update-ref", "refs/tags/v99.0.0", wrong], Path(bare_path)) + os.environ["PUBER_MODE"] = "normal" + rejected("remote tag mismatch", lambda: publish_fixture.helper.read_release(work, "v99.0.0", target)) + sh(["git", "update-ref", "refs/tags/v99.0.0", target], Path(bare_path)) + + checksum_file.write_bytes(b"\xff\xfe") + rejected("invalid UTF-8 checksum", lambda: publish_fixture.helper.read_release(work, "v99.0.0", target)) + checksum_file.write_text(hashlib.sha256(apk_file.read_bytes()).hexdigest() + " puber-v99.0.0.apk\n", encoding="utf-8") + + for mutation in ("asset_cardinality", "asset_id", "asset_size", "checksum_digest"): + candidate = json.loads(json.dumps(release_report)) + if mutation == "asset_cardinality": + candidate["assets"] = candidate["assets"][:1] + elif mutation == "asset_id": + candidate["assets"][0]["id"] = 99 + elif mutation == "asset_size": + candidate["assets"][0]["size"] += 1 + else: + candidate["checksum"]["parsed_checksum_digest"] = "b" * 64 + encoded = publish_fixture.helper.canonical_json(candidate) + rejected(mutation, lambda encoded=encoded, candidate=candidate: publish_fixture.helper.parse_release_report(encoded, hashlib.sha256(encoded.encode()).hexdigest(), expected_publication_digest=candidate["publication_report_digest"], expected_notes_digest=candidate["notes_digest"], expected_tag="v99.0.0", expected_target=target)) + + body_file.write_text((work / published["release_notes_path"]).read_text(encoding="utf-8"), encoding="utf-8") + calls_file.write_text("", encoding="utf-8") + os.environ["PUBER_MODE"] = "normal" + env["PUBER_MODE"] = "normal" + already_correct = subprocess.run([str(work/".kent/scripts/workflow-wait-github-release")], cwd=work, input=json.dumps(monitor_payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert already_correct.returncode == 0 and json.loads(already_correct.stdout)["transition"] == "release_release_published" + assert not any("release" in line and "edit" in line for line in calls_file.read_text(encoding="utf-8").splitlines()) + + body_file.write_text("", encoding="utf-8") + calls_file.write_text("", encoding="utf-8") + os.environ["PUBER_MODE"] = "block" + env["PUBER_MODE"] = "block" + blocked_result = subprocess.run([str(work/".kent/scripts/workflow-wait-github-release")], cwd=work, input=json.dumps(monitor_payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert blocked_result.returncode == 0 + blocked = json.loads(blocked_result.stdout) + assert blocked["transition"] == "monitor_needs_user_action", blocked + assert blocked["release_run"] != monitor_payload["release_run"] + + retry = {"_kent": {**monitor_payload["_kent"], "transition_branch_key": "monitor_needs_user_action"}} + retry.update({key: blocked[key] for key in ("blocker_reason", "workspace_path", "operation_id", "pr_url", "branch_name", "release_version", "release_tag", "target_commit", "tag_push_status", "release_notes_path", "publication_report", "publication_report_digest", "release_run")}) + calls_file.write_text("", encoding="utf-8") + os.environ["PUBER_MODE"] = "no_discovery" + env["PUBER_MODE"] = "no_discovery" + body_file.write_text((work / published["release_notes_path"]).read_text(encoding="utf-8"), encoding="utf-8") + retry_result = subprocess.run([str(work/".kent/scripts/workflow-wait-github-release")], cwd=work, input=json.dumps(retry), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert retry_result.returncode == 0 and json.loads(retry_result.stdout)["transition"] == "release_release_published" + assert not any("/actions/runs?" in line for line in calls_file.read_text(encoding="utf-8").splitlines()) + + os.environ["PUBER_MODE"] = "edit_lost_ack" + env["PUBER_MODE"] = "edit_lost_ack" + body_file.write_text("", encoding="utf-8") + lost_ack = subprocess.run([str(work/".kent/scripts/workflow-wait-github-release")], cwd=work, input=json.dumps(monitor_payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert lost_ack.returncode == 0 and json.loads(lost_ack.stdout)["transition"] == "release_release_published" + + os.environ["PUBER_MODE"] = "normal" + env["PUBER_MODE"] = "edit_mismatch" + body_file.write_text("", encoding="utf-8") + mismatch = subprocess.run([str(work/".kent/scripts/workflow-wait-github-release")], cwd=work, input=json.dumps(monitor_payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert mismatch.returncode == 0 and json.loads(mismatch.stdout)["transition"] == "monitor_needs_user_action" +print("workflow-wait-github-release: passed scenarios=happy,selected_run_binding,run_job_rows,remote_tag,invalid_utf8,asset_checksum,body_correct,block_retry_no_discovery,edit_lost_ack,post_edit_readback") diff --git a/.kent/scripts/workflow-puber-release-intent b/.kent/scripts/workflow-puber-release-intent index da66efb4..9d452dd1 100755 --- a/.kent/scripts/workflow-puber-release-intent +++ b/.kent/scripts/workflow-puber-release-intent @@ -16,6 +16,8 @@ WORKFLOW_ID = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" WORKFLOW_NAME = "Puber Release" WORKFLOW_REVISION = 88 REPOSITORY = "rovkinmax/Puber" +PUBER_WORKSPACE_ID = "workspace-fd8bc75f-ff8c-43b5-832f-fa14f7fa7459" +PUBER_PRIMARY_ROOT = "/Users/rovkinmax/dev/android/Puber" SCRIPT_PATHS = { "release_intent_gate": ".kent/scripts/workflow-puber-release-intent", "publish": ".kent/scripts/workflow-puber-release-publish", @@ -163,41 +165,50 @@ def session_worktree_status(root: Path, session_id: str) -> dict[str, Any]: raise ContractError(result.stderr.strip() or result.stdout.strip() or "Kent worktree status failed") return _json_one(result.stdout.encode("utf-8")) -def session_targets_worktree(root: Path, session_id: str) -> bool: - payload = session_worktree_status(root, session_id) +def _expected_primary_identity() -> tuple[str, Path]: + test_id = os.environ.get("PUBER_TEST_PRIMARY_WORKSPACE_ID", "") + test_root = os.environ.get("PUBER_TEST_PRIMARY_WORKSPACE_ROOT", "") + if bool(test_id) != bool(test_root): + raise ContractError("test primary workspace identity must be paired") + if test_id or test_root: + if not os.environ.get("PUBER_TEST_MODE") == "1": + raise ContractError("test primary workspace identity is not allowed") + return require_string(test_id, "test primary workspace id"), Path(require_string(test_root, "test primary workspace root")).expanduser().resolve() + return PUBER_WORKSPACE_ID, Path(PUBER_PRIMARY_ROOT).resolve() + +def _validate_session_status(root: Path, payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any], Path]: + if set(payload) != {"target", "worktree", "problems"} or payload.get("problems") != []: + raise ContractError("Kent worktree status is not the exact clean closed shape") target = payload.get("target") worktree = payload.get("worktree") - if not isinstance(target, dict) or not isinstance(worktree, dict): - raise ContractError("Kent worktree status is missing target/worktree objects") - values = { - "effective_target": target.get("EffectiveWorkdir"), - "recorded_target": worktree.get("recorded_root"), - "observed_target": worktree.get("observed_root"), - } - if any(not isinstance(value, str) or not value.strip() for value in values.values()): - raise ContractError("Kent worktree status has incomplete target evidence") - normalized = {key: Path(value).expanduser().resolve() for key, value in values.items()} - return all(value == root for value in normalized.values()) + if not isinstance(target, dict) or set(target) != {"WorkspaceID", "WorkspaceName", "WorkspaceRoot", "WorkspaceAvailability", "Worktree", "CwdRelpath", "EffectiveWorkdir"}: + raise ContractError("Kent worktree status target is not the stock closed shape") + if not isinstance(worktree, dict) or set(worktree) != {"recorded_root", "observed_root"}: + raise ContractError("Kent worktree status roots are not the stock closed shape") + workspace_id, primary = _expected_primary_identity() + if target["WorkspaceID"] != workspace_id or target["WorkspaceName"] != "Puber" or not all(isinstance(target.get(key), str) and target[key].strip() for key in ("WorkspaceRoot", "EffectiveWorkdir")) or target["WorkspaceAvailability"] != "available" or target["CwdRelpath"] != "." or Path(target["WorkspaceRoot"]).expanduser().resolve() != primary: + raise ContractError("Kent worktree status workspace identity drifted") + for key in ("recorded_root", "observed_root"): + if not isinstance(worktree[key], str) or not worktree[key].strip(): + raise ContractError("Kent worktree status root is incomplete") + return target, worktree, primary + +def session_targets_worktree(root: Path, session_id: str) -> bool: + payload = session_worktree_status(root, session_id) + target, roots, _ = _validate_session_status(root, payload) + task_worktree = target.get("Worktree") + if not isinstance(task_worktree, dict) or set(task_worktree) != {"ID", "Name", "Root", "Availability"}: + raise ContractError("Kent worktree status target Worktree is not the stock shape") + if not all(isinstance(task_worktree.get(key), str) and task_worktree[key].strip() for key in ("ID", "Name", "Root")) or task_worktree["Availability"] != "available": + raise ContractError("Kent worktree status target Worktree is incomplete") + return Path(target["EffectiveWorkdir"]).expanduser().resolve() == root and Path(task_worktree["Root"]).expanduser().resolve() == root and all(Path(roots[key]).expanduser().resolve() == root for key in ("recorded_root", "observed_root")) def session_preserves_worktree_identity(root: Path, session_id: str) -> bool: payload = session_worktree_status(root, session_id) - target = payload.get("target") - worktree = payload.get("worktree") - if not isinstance(target, dict) or not isinstance(worktree, dict): - raise ContractError("Kent worktree status is missing target/worktree objects") - recorded = worktree.get("recorded_root") - observed = worktree.get("observed_root") - effective = target.get("EffectiveWorkdir") - if any(not isinstance(value, str) or not value.strip() for value in (recorded, observed, effective)): - raise ContractError("Kent worktree status has incomplete retry identity evidence") - recorded_path = Path(recorded).expanduser().resolve() - observed_path = Path(observed).expanduser().resolve() - effective_path = Path(effective).expanduser().resolve() - if recorded_path != root or observed_path != root: - return False - if effective_path == root or root in effective_path.parents: + target, roots, primary = _validate_session_status(root, payload) + if target["Worktree"] is not None: return False - return True + return Path(target["EffectiveWorkdir"]).expanduser().resolve() == primary and all(Path(roots[key]).expanduser().resolve() == primary for key in ("recorded_root", "observed_root")) def task_show(root: Path, task_id: str) -> dict[str, Any]: result = run([_kent_bin(root), "task", "show", task_id, "--project", PROJECT_ID, "--json"], cwd=root, env=child_env(), check=False) @@ -300,7 +311,7 @@ def _kit_modules() -> dict[str, Any]: raise ContractError("Kit import escaped the selected root") runtime = importlib.import_module("workflowkit.runtime") result = {name: getattr(workflowkit, name) for name in ("ProjectProfile","ReleaseSpec","NormalizedGitHubWorkflowSourceV1","preflight_project_revision","capture_runtime_execution_context","capture_runtime_authority_binding","validate_operation_jobs","canonicalize_publication_operation")} - result.update({name: getattr(runtime, name) for name in ("validate_terminal_seal_request","validate_terminal_marker","parse_terminal_marker_line","terminal_marker_line","validate_cleanup_report")}) + result.update({name: getattr(runtime, name) for name in ("validate_terminal_seal_request","validate_terminal_marker","parse_terminal_marker_line","terminal_marker_line","validate_cleanup_report","validate_terminal_chain")}) return result def _external_captures(root: Path, profile: Any, selected: Any) -> list[tuple[str, str, bytes]]: @@ -674,6 +685,10 @@ def parse_release_report(raw: Any, digest: Any, *, expected_publication_digest: run = parse_run_carrier(value["run"]) if run == NOT_SELECTED_RUN: raise ContractError("release report run is not selected") + if expected_target and run["head_sha"] != expected_target: + raise ContractError("release report selected run target drifted") + if expected_tag and (run["head_branch"] != expected_tag or run["ref"] != f"refs/tags/{expected_tag}"): + raise ContractError("release report selected run tag/ref drifted") if not isinstance(value.get("jobs"), dict) or set(value["jobs"]) != {"run", "job"}: raise ContractError("release report jobs are not closed") run_api = parse_run_api(value["jobs"]["run"]) diff --git a/.kent/scripts/workflow-release-cleanup b/.kent/scripts/workflow-release-cleanup index eff2d276..5fe82a7c 100755 --- a/.kent/scripts/workflow-release-cleanup +++ b/.kent/scripts/workflow-release-cleanup @@ -42,7 +42,7 @@ def _session_for_cleanup(state: dict, prior_session: str | None = None) -> str: return prior_session return _eligible_session(state) -def _parse_janitor_report(raw: str, task_short_id: str) -> tuple[dict, str, str]: +def _parse_janitor_report(raw: str, task_short_id: str) -> tuple[dict, str, str, str]: if not isinstance(raw, str) or len(raw.encode("utf-8")) > 2 * 1024 * 1024: raise helper.ContractError("Janitor cleanup_report is unbounded or not text") lines = raw.splitlines() @@ -60,9 +60,11 @@ def _parse_janitor_report(raw: str, task_short_id: str) -> tuple[dict, str, str] if marker["task_short_id"] != task_short_id or marker_line != lines[1]: raise helper.ContractError("Janitor terminal evidence marker has the wrong task or is not canonical") suffix = "\n".join(lines[2:]) - if not suffix.startswith("Janitor ") or not suffix.strip() or len(suffix.encode("utf-8")) > 512 * 1024: + if not suffix.startswith("Janitor ") or not suffix.strip() or len(suffix.encode("utf-8")) > 512 * 1024 or any(line.startswith("TERMINAL_EVIDENCE_V1 ") for line in lines[2:]): raise helper.ContractError("Janitor cleanup_report suffix is not a bounded Janitor blocker") - return previous, marker_line, suffix + if marker["redaction"]["report_sha256"] != helper.sha256_bytes(lines[0].encode("utf-8")): + raise helper.ContractError("Janitor terminal marker redaction digest does not match the canonical report line") + return previous, marker_line, suffix, "\n".join(lines[:2]) def _validate_carriers(state: dict, carrier: dict) -> tuple[dict | None, dict | None]: publication = None @@ -100,12 +102,59 @@ def _validate_carriers(state: dict, carrier: dict) -> tuple[dict | None, dict | raise helper.ContractError("cleanup release/notes digest mismatch") return publication, release -def _cleanup_report(state: dict, session: str, payload: dict, *, operation_digest: str, unresolved: bool, blocker: str, fresh: dict, existing_marker: str | None = None) -> tuple[str, str]: +def _validate_terminal_marker_ledger(task_short_id: str, marker: dict[str, object], kit: dict[str, object]) -> None: + runtime_root = ROOT / ".kent" / "runtime" + candidates = [] + if runtime_root.is_dir() and not runtime_root.is_symlink(): + for ledger in runtime_root.rglob("evidence-ledger.jsonl"): + if ledger.is_file() and not ledger.is_symlink(): + candidates.append(ledger) + if len(candidates) != 1: + raise helper.ContractError("terminal evidence ledger is missing or ambiguous") + try: + records = [json.loads(line) for line in candidates[0].read_text(encoding="utf-8").splitlines() if line.strip()] + readback = kit["validate_terminal_chain"](records, task_short_id=task_short_id) + except Exception as error: + raise helper.ContractError(f"terminal evidence ledger readback is invalid: {error}") from error + if readback != marker: + raise helper.ContractError("terminal evidence marker does not match the sealed ledger") + +def _cleanup_report(state: dict, session: str, payload: dict, *, operation_digest: str, unresolved: bool, blocker: str, fresh: dict, existing_marker: str | None = None, preserved_prefix: str | None = None, preserved_suffix: str | None = None) -> tuple[str, str]: publication = payload.get("publication_report", "") release = payload.get("release_report", "") notes_path = payload.get("release_notes_path", "") value = {"schema": "puber_cleanup_report_v1", "operation_digest": operation_digest, "cleanup_session_id": session, "cleanup_mode": "report_only", "publication_report": publication, "publication_report_digest": payload.get("publication_report_digest", ""), "release_report": release, "release_report_digest": payload.get("release_report_digest", ""), "release_notes_path": notes_path, "release_notes_digest": payload.get("release_notes_digest", ""), "unresolved": unresolved, "destructive_action": False, "blocker_reason": blocker} encoded = helper.canonical_json(value) + if existing_marker is not None: + if preserved_prefix is None or preserved_suffix is None: + raise helper.ContractError("preserved terminal report context is incomplete") + prefix_lines = preserved_prefix.splitlines() + if len(prefix_lines) != 2 or prefix_lines[1] != existing_marker: + raise helper.ContractError("preserved terminal report prefix is not exact") + previous = helper.parse_closed_json(prefix_lines[0], "puber_cleanup_report_v1", helper.CLEANUP_KEYS) + if helper.canonical_json(previous) != prefix_lines[0]: + raise helper.ContractError("preserved terminal report line is not canonical") + try: + marker = fresh["kit"]["parse_terminal_marker_line"](existing_marker) + normalized_request = { + "schema": "terminal-evidence-seal-request-v1", + "operation_report_digests": marker["operation_report_digests"], + "redaction": marker["redaction"], + "retention_class": marker["retention_class"], + } + fresh["kit"]["validate_terminal_seal_request"](normalized_request) + fresh["kit"]["validate_cleanup_report"](preserved_prefix) + _validate_terminal_marker_ledger(state["task_short_id"], marker, fresh["kit"]) + except Exception as error: + raise helper.ContractError(f"preserved terminal evidence is invalid: {error}") from error + if marker["task_short_id"] != state["task_short_id"] or marker["redaction"]["report_sha256"] != helper.sha256_bytes(prefix_lines[0].encode("utf-8")): + raise helper.ContractError("preserved terminal evidence does not bind the canonical report line") + report = prefix_lines[0] + "\n" + preserved_suffix + "\n" + existing_marker + try: + fresh["kit"]["validate_cleanup_report"](report) + except Exception as error: + raise helper.ContractError(f"re-emitted Janitor report is not terminally sealed: {error}") from error + return report, helper.sha256_bytes(report.encode("utf-8")) if existing_marker is None: operation_digests = [{"kind": "runtime_source", "sha256": fresh["binding"].runtime_source_envelope_digest}] publication_digest = value["publication_report_digest"] @@ -128,10 +177,17 @@ def _cleanup_report(state: dict, session: str, payload: dict, *, operation_diges sealed = helper._json_one(result.stdout.encode("utf-8")) if set(sealed) != {"ledger_path", "terminal_marker", "event_hash"} or not helper.HEX64.fullmatch(str(sealed.get("event_hash", ""))): raise helper.ContractError("terminal evidence seal response is not closed") + expected_ledger = ROOT / ".kent" / "runtime" / state["task_short_id"] / "evidence-ledger.jsonl" + if sealed["ledger_path"] != str(expected_ledger): + raise helper.ContractError("terminal evidence ledger path is not the exact task-local path") marker_line = helper.require_string(sealed.get("terminal_marker"), "terminal_marker") marker = kit["parse_terminal_marker_line"](marker_line) if marker["task_short_id"] != state["task_short_id"]: raise helper.ContractError("terminal marker task identity drifted") + if sealed["event_hash"] != marker["final_hash"] or marker["retention_class"] != "cleanup_report_only" or marker["redaction"] != request["redaction"] or marker["operation_report_digests"] != kit["validate_terminal_seal_request"](request)["operation_report_digests"]: + raise helper.ContractError("terminal evidence seal response does not match the exact request") + if marker["redaction"]["report_sha256"] != helper.sha256_bytes(encoded.encode("utf-8")): + raise helper.ContractError("terminal evidence redaction digest does not match the canonical report line") kit["validate_cleanup_report"](encoded + "\n" + marker_line) except helper.ContractError: raise @@ -163,8 +219,11 @@ def main() -> int: prior_blocker = "" prior_session = None existing_marker = None + preserved_prefix = None + preserved_suffix = None if edge["key"] == "task_janitor_blocked": - previous, existing_marker, janitor_suffix = _parse_janitor_report(payload["cleanup_report"], state["task_short_id"]) + previous, existing_marker, janitor_suffix, preserved_prefix = _parse_janitor_report(payload["cleanup_report"], state["task_short_id"]) + preserved_suffix = janitor_suffix prior_session = helper.require_string(previous["cleanup_session_id"], "cleanup_session_id") carrier = { **payload, @@ -196,7 +255,7 @@ def main() -> int: raise helper.ContractError("cleanup branch carrier is not safe") carrier["branch_name"] = branch blocker = carrier.get("blocker_reason", "") or prior_blocker - report, report_digest = _cleanup_report(state, session, carrier, operation_digest=operation_digest, unresolved=prior_unresolved or bool(blocker), blocker=blocker, fresh=fresh, existing_marker=existing_marker) + report, report_digest = _cleanup_report(state, session, carrier, operation_digest=operation_digest, unresolved=prior_unresolved or bool(blocker), blocker=blocker, fresh=fresh, existing_marker=existing_marker, preserved_prefix=preserved_prefix, preserved_suffix=preserved_suffix) fields = {"cleanup_report": report, "workspace_path": str(ROOT), "branch_name": branch, "pr_url": carrier.get("pr_url", ""), "cleanup_mode": "report_only", "cleanup_session_id": session, "task_short_id": state["task_short_id"], "publication_report": carrier.get("publication_report", ""), "publication_report_digest": carrier.get("publication_report_digest", ""), "release_report": carrier.get("release_report", ""), "release_report_digest": carrier.get("release_report_digest", ""), "release_notes_path": carrier.get("release_notes_path", ""), "release_notes_digest": carrier.get("release_notes_digest", "")} helper.emit_exact(ROOT, "cleanup_task_janitor", "task_janitor", fields) return 0 diff --git a/.kent/scripts/workflow-wait-github-release b/.kent/scripts/workflow-wait-github-release index e3fd5173..c99818e9 100755 --- a/.kent/scripts/workflow-wait-github-release +++ b/.kent/scripts/workflow-wait-github-release @@ -76,8 +76,11 @@ def main() -> int: if body != notes_bytes.decode("utf-8"): edited = helper.gh(ROOT, ["release", "edit", payload["release_tag"], "--notes-file", "-"], input_text=notes_bytes.decode("utf-8")) if edited.returncode: - raise helper.ContractError("release notes edit failed after all verification gates") - release = helper.read_release(ROOT, payload["release_tag"], payload["target_commit"]) + release = helper.read_release(ROOT, payload["release_tag"], payload["target_commit"]) + if release.get("body") != notes_bytes.decode("utf-8"): + raise helper.ContractError("release notes edit failed after all verification gates") + else: + release = helper.read_release(ROOT, payload["release_tag"], payload["target_commit"]) if release.get("body") != notes_bytes.decode("utf-8"): raise helper.ContractError("release notes post-edit readback mismatch") final_publication, final_publication_digest = helper.publication_report(publication["operation_digest"], publication["pr_url"], publication["release_version"], publication["release_tag"], publication["target_commit"], notes, tag_phase="tag_published", tag_status=publication["tag_status"], unresolved=False, previous_operation_digest=publication.get("previous_operation_digest", "")) diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json index 9a4343cb..17678a32 100644 --- a/.kent/workflows/puber-release.manifest.json +++ b/.kent/workflows/puber-release.manifest.json @@ -61,17 +61,17 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-puber-release-intent=c3b476d7d04cf3c303fa04b0bd4ec68fe020e07d6fe0977652f0af6ed8f92be4", + "key": ".kent/scripts/tests/test-workflow-puber-release-intent=63e4ed4498511b78b7802c93fbab1386a70638ce079e365ca78d151340d7e0c6", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-puber-release-publish=f5972030528574b2269d2e8761e17c769871d839503f969c28a9506f33b8b564", + "key": ".kent/scripts/tests/test-workflow-puber-release-publish=78d7eb977dc7fb42cabb6754bde369d35834fe43f205372d523d82d8b74e1cbb", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-release-cleanup=6036a61f11af0d580644d12d590b4ddca2981faa2f40efe6e50919545959baf9", + "key": ".kent/scripts/tests/test-workflow-release-cleanup=4440d47fe9ae23afd1bc506167c88dd0f929843870a600178f0582df4b606e19", "runtime_digest_required": true }, { @@ -81,22 +81,22 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-wait-github-release=9cc0fcfa46c42fca87c430a5bf4ede0841890817dda20a68a472e3750996077c", + "key": ".kent/scripts/tests/test-workflow-wait-github-release=cbe45d2cae8b864f98db13aac71ad63d7c35b1afe7c014b799c57da3d5903d49", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-puber-release-intent=e63856f440d4525d5a1e9ce1cb7183ce55cdc76299d6710c8e59ecedd0d25919", + "key": ".kent/scripts/workflow-puber-release-intent=aee79d933a35f571cbf995a0f505f2cb59804c2a82ad98ed7ba2fa6417ff7c8a", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-release-cleanup=1521d3e59d913ca2e401718c860d4351807a51d167e407467bca377af4c1e5b1", + "key": ".kent/scripts/workflow-release-cleanup=e736b8f39eb201ab1b0f87a75440bb8a2600ba1c46eab987148acf827f02e937", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-wait-github-release=e80a040d4061ad643423f9d9776b58eecade83f02e04412182b73beec85d42ac", + "key": ".kent/scripts/workflow-wait-github-release=49d26a4095453a57a2b48ec846cd1e1e1f753015329ac7c5d09752321d6495ef", "runtime_digest_required": true }, { From ea8093534f245ec6159d743851804ff6ceefac69 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 07:57:00 +0500 Subject: [PATCH 13/21] Bind cleanup identity and normalize repeated Janitor retries --- .../tests/test-workflow-release-cleanup | 28 +++++- .kent/scripts/workflow-puber-release-intent | 85 ++++++++++++------- .kent/scripts/workflow-release-cleanup | 63 ++++++++------ .kent/workflows/puber-release.manifest.json | 6 +- 4 files changed, 121 insertions(+), 61 deletions(-) diff --git a/.kent/scripts/tests/test-workflow-release-cleanup b/.kent/scripts/tests/test-workflow-release-cleanup index ffa6392d..0b89832a 100755 --- a/.kent/scripts/tests/test-workflow-release-cleanup +++ b/.kent/scripts/tests/test-workflow-release-cleanup @@ -149,7 +149,7 @@ with tempfile.TemporaryDirectory() as directory: f" if test -f '{moved}'; then\n" f" printf '%s\\n' '{{\"target\":{{\"WorkspaceID\":\"{workspace_id}\",\"WorkspaceName\":\"Puber\",\"WorkspaceRoot\":\"{primary}\",\"WorkspaceAvailability\":\"available\",\"Worktree\":null,\"CwdRelpath\":\".\",\"EffectiveWorkdir\":\"{primary}\"}},\"worktree\":{{\"recorded_root\":\"{primary}\",\"observed_root\":\"{primary}\"}},\"problems\":[]}}'\n" " else\n" - f" printf '%s\\n' '{{\"target\":{{\"WorkspaceID\":\"{workspace_id}\",\"WorkspaceName\":\"Puber\",\"WorkspaceRoot\":\"{primary}\",\"WorkspaceAvailability\":\"available\",\"Worktree\":{{\"ID\":\"worktree-fixture\",\"Name\":\"PUB-99\",\"Root\":\"{managed}\",\"Availability\":\"available\"}},\"CwdRelpath\":\".\",\"EffectiveWorkdir\":\"{managed}\"}},\"worktree\":{{\"recorded_root\":\"{managed}\",\"observed_root\":\"{managed}\"}},\"problems\":[]}}'\n" + f" printf '%s\\n' '{{\"target\":{{\"WorkspaceID\":\"{workspace_id}\",\"WorkspaceName\":\"Puber\",\"WorkspaceRoot\":\"{primary}\",\"WorkspaceAvailability\":\"available\",\"Worktree\":{{\"ID\":\"worktree-fixture\",\"Name\":\"PUB-99\",\"Root\":\"{managed}\",\"Availability\":\"available\"}},\"CwdRelpath\":\".\",\"EffectiveWorkdir\":\"{managed}\"}},\"worktree\":{{\"recorded_root\":\"{managed}\",\"observed_root\":\"{managed}\",\"display_name\":\"PUB-99\",\"recorded_branch_ref\":\"refs/heads/release/99.0.0\"}},\"problems\":[]}}'\n" " fi\n" " exit 0\n" "fi\n" @@ -165,9 +165,6 @@ with tempfile.TemporaryDirectory() as directory: **os.environ, "HOME": str(root / "home"), "PYTHONDONTWRITEBYTECODE": "1", - "PUBER_TEST_MODE": "1", - "PUBER_TEST_PRIMARY_WORKSPACE_ID": workspace_id, - "PUBER_TEST_PRIMARY_WORKSPACE_ROOT": str(primary), "KENT_ENGINEERING_KIT_ROOT": str(KIT), "PUBER_KENT_BIN": str(kent), "PUBER_KENT_WORKTREE_BIN": str(wrapper), @@ -307,6 +304,29 @@ with tempfile.TemporaryDirectory() as directory: assert "still targets the task worktree" not in second_output.get("blocker_reason", "") assert "scheduled worktree deletion" in second_output["cleanup_report"], (second_output["blocker_reason"], second_output["cleanup_report"].splitlines()[-1]) + third_retry = {**retry, "cleanup_report": second_output["cleanup_report"]} + third_result = subprocess.run([str(managed / ".kent/scripts/workflow-release-cleanup")], cwd=managed, input=json.dumps(third_retry), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert third_result.returncode == 0, third_result.stderr + third_output = json.loads(third_result.stdout) + third_lines = third_output["cleanup_report"].splitlines() + assert third_output["cleanup_session_id"] == session_id + assert third_lines[0] == initial_lines[0] and third_lines[-1] == initial_lines[1] + assert len(third_lines) > len(retry_output["cleanup_report"].splitlines()) + + third_janitor = subprocess.run( + [str(managed / ".kent/scripts/workflow-task-janitor")], + cwd=managed, + input=json.dumps({"workspace_path": str(managed), "branch_name": "release/99.0.0", "pr_url": "", "cleanup_mode": "report_only", "cleanup_session_id": session_id, "cleanup_report": third_output["cleanup_report"], "task_short_id": "PUB-99"}), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + assert third_janitor.returncode == 0 + third_janitor_output = json.loads(third_janitor.stdout) + assert third_janitor_output["transition"] == "task_janitor_blocked" + assert "still targets the task worktree" not in third_janitor_output.get("blocker_reason", "") + tampered = initial_lines[0][:-1] + "0\n" + initial_lines[1] + "\nJanitor tampered" expect_rejected([str(managed / ".kent/scripts/workflow-release-cleanup")], managed, {**retry, "cleanup_report": tampered}, env, "tampered canonical line") expect_rejected([str(managed / ".kent/scripts/workflow-release-cleanup")], managed, {**retry, "cleanup_report": initial_lines[0] + "\n" + initial_lines[1][:-1] + "0\nJanitor tampered"}, env, "tampered marker") diff --git a/.kent/scripts/workflow-puber-release-intent b/.kent/scripts/workflow-puber-release-intent index 9d452dd1..b708d263 100755 --- a/.kent/scripts/workflow-puber-release-intent +++ b/.kent/scripts/workflow-puber-release-intent @@ -16,8 +16,6 @@ WORKFLOW_ID = "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459" WORKFLOW_NAME = "Puber Release" WORKFLOW_REVISION = 88 REPOSITORY = "rovkinmax/Puber" -PUBER_WORKSPACE_ID = "workspace-fd8bc75f-ff8c-43b5-832f-fa14f7fa7459" -PUBER_PRIMARY_ROOT = "/Users/rovkinmax/dev/android/Puber" SCRIPT_PATHS = { "release_intent_gate": ".kent/scripts/workflow-puber-release-intent", "publish": ".kent/scripts/workflow-puber-release-publish", @@ -38,7 +36,7 @@ NOT_SELECTED_RUN = {"schema": "puber_release_run_v1", "state": "not_selected"} RUN_KEYS = {"schema", "state", "repository", "workflow_path", "workflow_name", "event", "run_id", "attempt", "head_sha", "ref", "head_branch"} PUBLICATION_KEYS = {"schema", "operation_digest", "previous_operation_digest", "pr_url", "release_version", "release_tag", "target_commit", "notes_report", "notes_digest", "tag_phase", "tag_status", "unresolved"} RELEASE_KEYS = {"schema", "observation_operation_digest", "observation_provenance_fingerprint", "run", "jobs", "release", "assets", "checksum", "notes_digest", "publication_report_digest", "unresolved"} -CLEANUP_KEYS = {"schema", "operation_digest", "cleanup_session_id", "cleanup_mode", "publication_report", "publication_report_digest", "release_report", "release_report_digest", "release_notes_path", "release_notes_digest", "unresolved", "destructive_action", "blocker_reason"} +CLEANUP_KEYS = {"schema", "operation_digest", "cleanup_session_id", "cleanup_mode", "session_identity", "publication_report", "publication_report_digest", "release_report", "release_report_digest", "release_notes_path", "release_notes_digest", "unresolved", "destructive_action", "blocker_reason"} NOTES_KEYS = {"schema", "path", "mode", "bytes", "sha256", "encoding", "line_endings", "final_newline", "producer", "previous_tag", "range", "target_commit"} RUN_API_KEYS = {"run_id", "attempt", "path", "name", "event", "head_sha", "head_branch", "status", "conclusion"} JOB_KEYS = {"id", "name", "status", "conclusion", "attempt"} @@ -165,16 +163,21 @@ def session_worktree_status(root: Path, session_id: str) -> dict[str, Any]: raise ContractError(result.stderr.strip() or result.stdout.strip() or "Kent worktree status failed") return _json_one(result.stdout.encode("utf-8")) -def _expected_primary_identity() -> tuple[str, Path]: - test_id = os.environ.get("PUBER_TEST_PRIMARY_WORKSPACE_ID", "") - test_root = os.environ.get("PUBER_TEST_PRIMARY_WORKSPACE_ROOT", "") - if bool(test_id) != bool(test_root): - raise ContractError("test primary workspace identity must be paired") - if test_id or test_root: - if not os.environ.get("PUBER_TEST_MODE") == "1": - raise ContractError("test primary workspace identity is not allowed") - return require_string(test_id, "test primary workspace id"), Path(require_string(test_root, "test primary workspace root")).expanduser().resolve() - return PUBER_WORKSPACE_ID, Path(PUBER_PRIMARY_ROOT).resolve() +def git_common_root(root: Path) -> Path: + common = Path(git(root, "rev-parse", "--git-common-dir")) + if not common.is_absolute(): + common = root / common + common = common.expanduser().resolve() + if common.name != ".git": + raise ContractError("Git common directory is not a .git directory") + primary = common.parent + if git(primary, "rev-parse", "--show-toplevel") != str(primary) or git(primary, "status", "--porcelain=v1", "--untracked-files=all"): + raise ContractError("primary checkout is not the exact clean Git root") + return primary + +def current_branch_ref(root: Path) -> str: + branch = require_string(git(root, "symbolic-ref", "--quiet", "--short", "HEAD"), "current branch") + return f"refs/heads/{branch}" def _validate_session_status(root: Path, payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any], Path]: if set(payload) != {"target", "worktree", "problems"} or payload.get("problems") != []: @@ -183,32 +186,56 @@ def _validate_session_status(root: Path, payload: dict[str, Any]) -> tuple[dict[ worktree = payload.get("worktree") if not isinstance(target, dict) or set(target) != {"WorkspaceID", "WorkspaceName", "WorkspaceRoot", "WorkspaceAvailability", "Worktree", "CwdRelpath", "EffectiveWorkdir"}: raise ContractError("Kent worktree status target is not the stock closed shape") - if not isinstance(worktree, dict) or set(worktree) != {"recorded_root", "observed_root"}: + if not isinstance(worktree, dict) or set(worktree) not in ({"recorded_root", "observed_root", "display_name", "recorded_branch_ref"}, {"recorded_root", "observed_root"}): raise ContractError("Kent worktree status roots are not the stock closed shape") - workspace_id, primary = _expected_primary_identity() - if target["WorkspaceID"] != workspace_id or target["WorkspaceName"] != "Puber" or not all(isinstance(target.get(key), str) and target[key].strip() for key in ("WorkspaceRoot", "EffectiveWorkdir")) or target["WorkspaceAvailability"] != "available" or target["CwdRelpath"] != "." or Path(target["WorkspaceRoot"]).expanduser().resolve() != primary: + primary = git_common_root(root) + if not isinstance(target["WorkspaceID"], str) or not target["WorkspaceID"].strip() or not all(isinstance(target.get(key), str) and target[key].strip() for key in ("WorkspaceName", "WorkspaceRoot", "EffectiveWorkdir")) or target["WorkspaceAvailability"] != "available" or target["CwdRelpath"] != "." or Path(target["WorkspaceRoot"]).expanduser().resolve() != primary: raise ContractError("Kent worktree status workspace identity drifted") for key in ("recorded_root", "observed_root"): if not isinstance(worktree[key], str) or not worktree[key].strip(): raise ContractError("Kent worktree status root is incomplete") + if set(worktree) == {"recorded_root", "observed_root", "display_name", "recorded_branch_ref"}: + if worktree["display_name"] != target.get("Worktree", {}).get("Name") or worktree["recorded_branch_ref"] != current_branch_ref(root): + raise ContractError("Kent worktree status display or branch identity drifted") return target, worktree, primary -def session_targets_worktree(root: Path, session_id: str) -> bool: +def session_identity(root: Path, session_id: str, *, moved: bool = False) -> dict[str, str]: payload = session_worktree_status(root, session_id) - target, roots, _ = _validate_session_status(root, payload) + target, roots, primary = _validate_session_status(root, payload) + if moved: + if set(roots) != {"recorded_root", "observed_root"} or target["Worktree"] is not None or Path(target["EffectiveWorkdir"]).expanduser().resolve() != primary or any(Path(roots[key]).expanduser().resolve() != primary for key in ("recorded_root", "observed_root")): + raise ContractError("Kent post-leave Session identity is not exact") + return { + "workspace_id": target["WorkspaceID"], + "workspace_name": target["WorkspaceName"], + "workspace_root": str(primary), + "worktree_id": "", + "worktree_name": "", + "worktree_root": str(primary), + "recorded_branch_ref": "", + } task_worktree = target.get("Worktree") - if not isinstance(task_worktree, dict) or set(task_worktree) != {"ID", "Name", "Root", "Availability"}: - raise ContractError("Kent worktree status target Worktree is not the stock shape") - if not all(isinstance(task_worktree.get(key), str) and task_worktree[key].strip() for key in ("ID", "Name", "Root")) or task_worktree["Availability"] != "available": - raise ContractError("Kent worktree status target Worktree is incomplete") - return Path(target["EffectiveWorkdir"]).expanduser().resolve() == root and Path(task_worktree["Root"]).expanduser().resolve() == root and all(Path(roots[key]).expanduser().resolve() == root for key in ("recorded_root", "observed_root")) + if set(roots) != {"recorded_root", "observed_root", "display_name", "recorded_branch_ref"} or not isinstance(task_worktree, dict) or set(task_worktree) != {"ID", "Name", "Root", "Availability"} or task_worktree["Availability"] != "available": + raise ContractError("Kent active Session Worktree is not exact") + if Path(target["EffectiveWorkdir"]).expanduser().resolve() != root or Path(task_worktree["Root"]).expanduser().resolve() != root or any(Path(roots[key]).expanduser().resolve() != root for key in ("recorded_root", "observed_root")): + raise ContractError("Kent active Session roots do not match the selected worktree") + return { + "workspace_id": target["WorkspaceID"], + "workspace_name": target["WorkspaceName"], + "workspace_root": str(primary), + "worktree_id": task_worktree["ID"], + "worktree_name": task_worktree["Name"], + "worktree_root": str(root), + "recorded_branch_ref": roots["recorded_branch_ref"], + } + +def session_targets_worktree(root: Path, session_id: str) -> bool: + session_identity(root, session_id) + return True def session_preserves_worktree_identity(root: Path, session_id: str) -> bool: - payload = session_worktree_status(root, session_id) - target, roots, primary = _validate_session_status(root, payload) - if target["Worktree"] is not None: - return False - return Path(target["EffectiveWorkdir"]).expanduser().resolve() == primary and all(Path(roots[key]).expanduser().resolve() == primary for key in ("recorded_root", "observed_root")) + session_identity(root, session_id, moved=True) + return True def task_show(root: Path, task_id: str) -> dict[str, Any]: result = run([_kent_bin(root), "task", "show", task_id, "--project", PROJECT_ID, "--json"], cwd=root, env=child_env(), check=False) @@ -311,7 +338,7 @@ def _kit_modules() -> dict[str, Any]: raise ContractError("Kit import escaped the selected root") runtime = importlib.import_module("workflowkit.runtime") result = {name: getattr(workflowkit, name) for name in ("ProjectProfile","ReleaseSpec","NormalizedGitHubWorkflowSourceV1","preflight_project_revision","capture_runtime_execution_context","capture_runtime_authority_binding","validate_operation_jobs","canonicalize_publication_operation")} - result.update({name: getattr(runtime, name) for name in ("validate_terminal_seal_request","validate_terminal_marker","parse_terminal_marker_line","terminal_marker_line","validate_cleanup_report","validate_terminal_chain")}) + result.update({name: getattr(runtime, name) for name in ("validate_terminal_seal_request","validate_terminal_marker","parse_terminal_marker_line","terminal_marker_line","validate_cleanup_report","validate_terminal_chain","canonical_sha256")}) return result def _external_captures(root: Path, profile: Any, selected: Any) -> list[tuple[str, str, bytes]]: diff --git a/.kent/scripts/workflow-release-cleanup b/.kent/scripts/workflow-release-cleanup index 5fe82a7c..fbdb95a8 100755 --- a/.kent/scripts/workflow-release-cleanup +++ b/.kent/scripts/workflow-release-cleanup @@ -14,8 +14,9 @@ helper = importlib.util.module_from_spec(_spec) assert _spec and _spec.loader _spec.loader.exec_module(helper) ROOT = helper.ROOT +SESSION_IDENTITY_KEYS = {"workspace_id", "workspace_name", "workspace_root", "worktree_id", "worktree_name", "worktree_root", "recorded_branch_ref"} -def _eligible_session(state: dict) -> str: +def _eligible_session(state: dict) -> tuple[str, dict[str, str]]: rows = state["task"].get("live_sessions") if not isinstance(rows, list): raise helper.ContractError("Task has no live session rows") @@ -29,17 +30,18 @@ def _eligible_session(state: dict) -> str: helper.require_string(row.get("session_name"), "session_name") helper.require_string(row.get("node_display_name"), "node_display_name") if helper.session_targets_worktree(ROOT, session_id): - candidates.append(session_id) + candidates.append((session_id, helper.session_identity(ROOT, session_id))) if len(candidates) != 1: raise helper.ContractError("exactly one stock Kent Session must target this task worktree") return candidates[0] -def _session_for_cleanup(state: dict, prior_session: str | None = None) -> str: +def _session_for_cleanup(state: dict, prior_session: str | None = None, prior_identity: dict[str, str] | None = None) -> tuple[str, dict[str, str]]: if prior_session is not None: prior_session = helper.require_string(prior_session, "cleanup_session_id") - if not helper.session_preserves_worktree_identity(ROOT, prior_session): - raise helper.ContractError("prior cleanup Session no longer preserves this task worktree identity") - return prior_session + current = helper.session_identity(ROOT, prior_session, moved=True) + if not isinstance(prior_identity, dict) or any(current.get(key) != prior_identity.get(key) for key in ("workspace_id", "workspace_name", "workspace_root")): + raise helper.ContractError("prior cleanup Session workspace identity drifted") + return prior_session, prior_identity return _eligible_session(state) def _parse_janitor_report(raw: str, task_short_id: str) -> tuple[dict, str, str, str]: @@ -51,20 +53,30 @@ def _parse_janitor_report(raw: str, task_short_id: str) -> tuple[dict, str, str, previous = helper.parse_closed_json(lines[0], "puber_cleanup_report_v1", helper.CLEANUP_KEYS) if helper.canonical_json(previous) != lines[0]: raise helper.ContractError("Janitor cleanup_report first line is not canonical") + identity = previous.get("session_identity") + if not isinstance(identity, dict) or set(identity) != SESSION_IDENTITY_KEYS or any(not isinstance(identity[key], str) or not identity[key].strip() for key in SESSION_IDENTITY_KEYS - {"recorded_branch_ref"}): + raise helper.ContractError("Janitor cleanup_report Session identity is not closed") kit = helper._kit_modules() + marker_indices = [index for index, line in enumerate(lines[1:], start=1) if line.startswith("TERMINAL_EVIDENCE_V1 ")] + if len(marker_indices) != 1: + raise helper.ContractError("Janitor cleanup_report must contain exactly one terminal marker") + marker_index = marker_indices[0] try: - marker = kit["validate_cleanup_report"]("\n".join(lines[:2])) + marker = kit["validate_cleanup_report"](lines[0] + "\n" + lines[marker_index]) marker_line = kit["terminal_marker_line"](marker) except Exception as error: raise helper.ContractError(f"Janitor terminal evidence marker is invalid: {error}") from error - if marker["task_short_id"] != task_short_id or marker_line != lines[1]: + if marker["task_short_id"] != task_short_id or marker_line != lines[marker_index]: raise helper.ContractError("Janitor terminal evidence marker has the wrong task or is not canonical") - suffix = "\n".join(lines[2:]) - if not suffix.startswith("Janitor ") or not suffix.strip() or len(suffix.encode("utf-8")) > 512 * 1024 or any(line.startswith("TERMINAL_EVIDENCE_V1 ") for line in lines[2:]): + annotations = [line for index, line in enumerate(lines[1:], start=1) if index != marker_index] + if not annotations or any(not line.startswith("Janitor ") or not line.strip() for line in annotations): raise helper.ContractError("Janitor cleanup_report suffix is not a bounded Janitor blocker") + suffix = "\n".join(annotations) + if len(suffix.encode("utf-8")) > 512 * 1024: + raise helper.ContractError("Janitor cleanup_report annotations are unbounded") if marker["redaction"]["report_sha256"] != helper.sha256_bytes(lines[0].encode("utf-8")): raise helper.ContractError("Janitor terminal marker redaction digest does not match the canonical report line") - return previous, marker_line, suffix, "\n".join(lines[:2]) + return previous, marker_line, suffix, lines[0] def _validate_carriers(state: dict, carrier: dict) -> tuple[dict | None, dict | None]: publication = None @@ -104,13 +116,12 @@ def _validate_carriers(state: dict, carrier: dict) -> tuple[dict | None, dict | def _validate_terminal_marker_ledger(task_short_id: str, marker: dict[str, object], kit: dict[str, object]) -> None: runtime_root = ROOT / ".kent" / "runtime" - candidates = [] - if runtime_root.is_dir() and not runtime_root.is_symlink(): - for ledger in runtime_root.rglob("evidence-ledger.jsonl"): - if ledger.is_file() and not ledger.is_symlink(): - candidates.append(ledger) - if len(candidates) != 1: - raise helper.ContractError("terminal evidence ledger is missing or ambiguous") + marker_digest = kit["canonical_sha256"](marker) + active = runtime_root / task_short_id / "evidence-ledger.jsonl" + tombstone = runtime_root / f".evidence-cleanup-{marker_digest}" / "evidence-ledger.jsonl" + candidates = [path for path in (active, tombstone) if path.exists()] + if len(candidates) != 1 or any(path.is_symlink() or not path.is_file() for path in candidates): + raise helper.ContractError("terminal evidence task ledger/tombstone is missing or ambiguous") try: records = [json.loads(line) for line in candidates[0].read_text(encoding="utf-8").splitlines() if line.strip()] readback = kit["validate_terminal_chain"](records, task_short_id=task_short_id) @@ -119,17 +130,17 @@ def _validate_terminal_marker_ledger(task_short_id: str, marker: dict[str, objec if readback != marker: raise helper.ContractError("terminal evidence marker does not match the sealed ledger") -def _cleanup_report(state: dict, session: str, payload: dict, *, operation_digest: str, unresolved: bool, blocker: str, fresh: dict, existing_marker: str | None = None, preserved_prefix: str | None = None, preserved_suffix: str | None = None) -> tuple[str, str]: +def _cleanup_report(state: dict, session: str, session_identity: dict[str, str], payload: dict, *, operation_digest: str, unresolved: bool, blocker: str, fresh: dict, existing_marker: str | None = None, preserved_prefix: str | None = None, preserved_suffix: str | None = None) -> tuple[str, str]: publication = payload.get("publication_report", "") release = payload.get("release_report", "") notes_path = payload.get("release_notes_path", "") - value = {"schema": "puber_cleanup_report_v1", "operation_digest": operation_digest, "cleanup_session_id": session, "cleanup_mode": "report_only", "publication_report": publication, "publication_report_digest": payload.get("publication_report_digest", ""), "release_report": release, "release_report_digest": payload.get("release_report_digest", ""), "release_notes_path": notes_path, "release_notes_digest": payload.get("release_notes_digest", ""), "unresolved": unresolved, "destructive_action": False, "blocker_reason": blocker} + value = {"schema": "puber_cleanup_report_v1", "operation_digest": operation_digest, "cleanup_session_id": session, "cleanup_mode": "report_only", "session_identity": dict(session_identity), "publication_report": publication, "publication_report_digest": payload.get("publication_report_digest", ""), "release_report": release, "release_report_digest": payload.get("release_report_digest", ""), "release_notes_path": notes_path, "release_notes_digest": payload.get("release_notes_digest", ""), "unresolved": unresolved, "destructive_action": False, "blocker_reason": blocker} encoded = helper.canonical_json(value) if existing_marker is not None: if preserved_prefix is None or preserved_suffix is None: raise helper.ContractError("preserved terminal report context is incomplete") prefix_lines = preserved_prefix.splitlines() - if len(prefix_lines) != 2 or prefix_lines[1] != existing_marker: + if len(prefix_lines) != 1: raise helper.ContractError("preserved terminal report prefix is not exact") previous = helper.parse_closed_json(prefix_lines[0], "puber_cleanup_report_v1", helper.CLEANUP_KEYS) if helper.canonical_json(previous) != prefix_lines[0]: @@ -143,7 +154,7 @@ def _cleanup_report(state: dict, session: str, payload: dict, *, operation_diges "retention_class": marker["retention_class"], } fresh["kit"]["validate_terminal_seal_request"](normalized_request) - fresh["kit"]["validate_cleanup_report"](preserved_prefix) + fresh["kit"]["validate_cleanup_report"](prefix_lines[0] + "\n" + existing_marker) _validate_terminal_marker_ledger(state["task_short_id"], marker, fresh["kit"]) except Exception as error: raise helper.ContractError(f"preserved terminal evidence is invalid: {error}") from error @@ -218,6 +229,7 @@ def main() -> int: prior_unresolved = False prior_blocker = "" prior_session = None + prior_identity = None existing_marker = None preserved_prefix = None preserved_suffix = None @@ -225,6 +237,7 @@ def main() -> int: previous, existing_marker, janitor_suffix, preserved_prefix = _parse_janitor_report(payload["cleanup_report"], state["task_short_id"]) preserved_suffix = janitor_suffix prior_session = helper.require_string(previous["cleanup_session_id"], "cleanup_session_id") + prior_identity = previous["session_identity"] carrier = { **payload, "branch_name": payload.get("branch_name", ""), @@ -239,11 +252,11 @@ def main() -> int: prior_blocker = janitor_suffix fresh = helper.attest(ROOT, state, "cleanup", edge["key"]) operation_digest = fresh["binding"].provenance_fingerprint - session = _session_for_cleanup(state, prior_session) + session, session_identity = _session_for_cleanup(state, prior_session, prior_identity) else: fresh = helper.attest(ROOT, state, "cleanup", edge["key"]) operation_digest = fresh["binding"].provenance_fingerprint - session = _session_for_cleanup(state) + session, session_identity = _session_for_cleanup(state) carrier = payload _validate_carriers(state, carrier) declarations = helper.declarations(state["task"].get("body")) @@ -255,7 +268,7 @@ def main() -> int: raise helper.ContractError("cleanup branch carrier is not safe") carrier["branch_name"] = branch blocker = carrier.get("blocker_reason", "") or prior_blocker - report, report_digest = _cleanup_report(state, session, carrier, operation_digest=operation_digest, unresolved=prior_unresolved or bool(blocker), blocker=blocker, fresh=fresh, existing_marker=existing_marker, preserved_prefix=preserved_prefix, preserved_suffix=preserved_suffix) + report, report_digest = _cleanup_report(state, session, session_identity, carrier, operation_digest=operation_digest, unresolved=prior_unresolved or bool(blocker), blocker=blocker, fresh=fresh, existing_marker=existing_marker, preserved_prefix=preserved_prefix, preserved_suffix=preserved_suffix) fields = {"cleanup_report": report, "workspace_path": str(ROOT), "branch_name": branch, "pr_url": carrier.get("pr_url", ""), "cleanup_mode": "report_only", "cleanup_session_id": session, "task_short_id": state["task_short_id"], "publication_report": carrier.get("publication_report", ""), "publication_report_digest": carrier.get("publication_report_digest", ""), "release_report": carrier.get("release_report", ""), "release_report_digest": carrier.get("release_report_digest", ""), "release_notes_path": carrier.get("release_notes_path", ""), "release_notes_digest": carrier.get("release_notes_digest", "")} helper.emit_exact(ROOT, "cleanup_task_janitor", "task_janitor", fields) return 0 diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json index 17678a32..82ed81af 100644 --- a/.kent/workflows/puber-release.manifest.json +++ b/.kent/workflows/puber-release.manifest.json @@ -71,7 +71,7 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-release-cleanup=4440d47fe9ae23afd1bc506167c88dd0f929843870a600178f0582df4b606e19", + "key": ".kent/scripts/tests/test-workflow-release-cleanup=eab3e55e97f30bd8d727edb0a048d1b6e713b1fa1eaa5a699f99cd28bf4bb5a5", "runtime_digest_required": true }, { @@ -86,12 +86,12 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-puber-release-intent=aee79d933a35f571cbf995a0f505f2cb59804c2a82ad98ed7ba2fa6417ff7c8a", + "key": ".kent/scripts/workflow-puber-release-intent=6fe9c4a3086ea73bd3b4cb8eee2d3c493d67f1a4bf42a76647b0571783c27f30", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-release-cleanup=e736b8f39eb201ab1b0f87a75440bb8a2600ba1c46eab987148acf827f02e937", + "key": ".kent/scripts/workflow-release-cleanup=2324f7cac3fc7cc07828731214f8566173807ae2da3e16e41ee61fde561cba8c", "runtime_digest_required": true }, { From 5bb57db165b3459eb1d9f902bb87f542a27968b3 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 08:15:27 +0500 Subject: [PATCH 14/21] Correct release cleanup evidence boundaries --- .../tests/test-workflow-release-cleanup | 323 ++++++++++++++++-- .kent/scripts/workflow-release-cleanup | 1 + .kent/workflows/puber-release.manifest.json | 4 +- 3 files changed, 289 insertions(+), 39 deletions(-) diff --git a/.kent/scripts/tests/test-workflow-release-cleanup b/.kent/scripts/tests/test-workflow-release-cleanup index 0b89832a..96f79626 100755 --- a/.kent/scripts/tests/test-workflow-release-cleanup +++ b/.kent/scripts/tests/test-workflow-release-cleanup @@ -10,6 +10,7 @@ import shutil import subprocess import tempfile from importlib.machinery import SourceFileLoader +import shlex ROOT = Path(__file__).resolve().parents[3] KIT = Path("/Users/rovkinmax/.kent/worktrees/kent-engineering-kit/s02a-kit-profile") @@ -44,6 +45,169 @@ def expect_rejected(command: list[str], cwd: Path, payload: dict, env: dict[str, assert result.returncode != 0 and result.stdout == "" and "error" in result.stderr, label +def append_fixture_ledger(worktree: Path, short_id: str) -> None: + sh( + [str(worktree / ".kent/scripts/workflow-evidence-ledger"), "append", "--task", short_id, "--workspace", str(worktree)], + worktree, + input=json.dumps({ + "node_key": "cleanup", + "evidence_type": "cleanup_fixture", + "summary": "Fresh cleanup edge ledger.", + "artifacts": ["fixture"], + "checks": ["edge"], + "decisions": ["report_only"], + "context": { + "manifest_path": ".kent/context/implement.md", + "files_read": [".kent/project-contract.md"], + "model_calls": 0, + "compaction_count": 0, + "repeated_questions": 0, + "verification_loops": 0, + }, + }), + ) + + +def make_edge_fixture(base: Path, short_id: str, version: str, cleanup_node: dict) -> dict: + primary = base / "home" / "dev" / "android" / "Puber" + worktree = base / "home" / ".kent" / "worktrees" / short_id + branch = f"release/{version}" + primary.parent.mkdir(parents=True) + sh(["git", "clone", "--quiet", str(ROOT), str(primary)], base) + bare = base / "origin.git" + sh(["git", "init", "--bare", str(bare)], base) + sh(["git", "remote", "set-url", "origin", str(bare)], primary) + worktree.parent.mkdir(parents=True) + sh(["git", "worktree", "add", "--quiet", "-b", branch, str(worktree), "HEAD"], primary) + + copied = { + ".kent/scripts/workflow-puber-release-intent", + ".kent/scripts/workflow-evidence-ledger", + ".kent/scripts/workflow-release-cleanup", + ".kent/scripts/workflow-task-janitor", + ".kent/scripts/tests/test-workflow-release-cleanup", + ".kent/scripts/workflow_runtime_contracts.py", + ".kent/workflows/puber-release.json", + ".kent/workflows/puber-release.manifest.json", + ".kent/workflows/specs/puber-release.toml", + ".kent/workflows/builders/puber_release.py", + } + for relative in sorted(copied): + source = KIT / "templates/project/workflow-task-janitor" if relative == ".kent/scripts/workflow-task-janitor" else ROOT / relative + target = worktree / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + notes = f"puber_release_notes_v1\n\n# Изменения\n\n- Проверка релиза {version}.\n".encode("utf-8") + notes_path = worktree / f".todo/{short_id}/release-notes-ru.md" + notes_path.parent.mkdir(parents=True, exist_ok=True) + notes_path.write_bytes(notes) + notes_path.chmod(0o600) + sh(["git", "config", "user.email", "test@example.invalid"], worktree) + sh(["git", "config", "user.name", "Puber Test"], worktree) + sh(["git", "add", "."], worktree) + sh(["git", "commit", "--quiet", "-m", "cleanup edge fixture"], worktree) + target = sh(["git", "rev-parse", "HEAD"], worktree).stdout.strip() + sh(["git", "push", "--quiet", "-u", "origin", branch], worktree) + + task_id = f"task-{int(short_id.split('-', 1)[1]):08x}-1234-4234-8234-123456789abc" + session_id = f"session-{short_id.lower()}" + workspace_id = f"workspace-{short_id.lower()}" + task = { + "actions": [], + "attention_count": 0, + "body": f"release_version: {version}\nrelease_tag: v{version}\nrelease_branch: {branch}\n", + "current_nodes": [{"node_id": cleanup_node["id"]}], + "current_scripts": [{"path": ".kent/scripts/workflow-release-cleanup", "current_node": {"node_id": cleanup_node["id"]}}], + "execution_target": {"commit_oid": target}, + "label_ids": [], + "live_sessions": [{"session_id": session_id, "session_name": "Cleanup", "node_display_name": "cleanup"}], + "project": {}, + "retained_session_count": 0, + "source_workspace": {}, + "status": {"node_ids": [cleanup_node["id"]]}, + "summary": {"id": task_id, "project_id": "project-6c1b6c4e-c8e2-4170-b9f7-a7d9c4602176", "workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "short_id": short_id}, + "workflow": {"workflow_id": "10d8adb2-c74c-4ef0-8b5c-311cb5cd0459", "version": 88, "name": "Puber Release"}, + "worktree_path": str(worktree), + } + fake = base / "fake" + fake.mkdir() + task_file = fake / "task.json" + task_file.write_text(json.dumps(task), encoding="utf-8") + kent = fake / "kent" + kent.write_text("#!/bin/sh\ncat \"$PUBER_FAKE_TASK\"\n", encoding="utf-8") + kent.chmod(0o755) + moved = fake / "moved" + active_status = json.dumps({ + "target": { + "WorkspaceID": workspace_id, + "WorkspaceName": "Puber", + "WorkspaceRoot": str(primary), + "WorkspaceAvailability": "available", + "Worktree": {"ID": f"worktree-{short_id.lower()}", "Name": short_id, "Root": str(worktree), "Availability": "available"}, + "CwdRelpath": ".", + "EffectiveWorkdir": str(worktree), + }, + "worktree": {"recorded_root": str(worktree), "observed_root": str(worktree), "display_name": short_id, "recorded_branch_ref": f"refs/heads/{branch}"}, + "problems": [], + }, separators=(",", ":")) + moved_status = json.dumps({ + "target": { + "WorkspaceID": workspace_id, + "WorkspaceName": "Puber", + "WorkspaceRoot": str(primary), + "WorkspaceAvailability": "available", + "Worktree": None, + "CwdRelpath": ".", + "EffectiveWorkdir": str(primary), + }, + "worktree": {"recorded_root": str(primary), "observed_root": str(primary)}, + "problems": [], + }, separators=(",", ":")) + wrapper = fake / "kent-worktree" + wrapper.write_text( + "#!/bin/sh\n" + "if test \"$1\" = status; then\n" + " test \"$2\" = --session && test \"$4\" = --json || exit 2\n" + f" if test -f {shlex.quote(str(moved))}; then printf '%s\\n' {shlex.quote(moved_status)}; " + f" else printf '%s\\n' {shlex.quote(active_status)}; fi\n" + " exit 0\n" + "fi\n" + "if test \"$1\" = delete; then\n" + " printf '%s\\n' '{\"kind\":\"scheduled\",\"scheduled\":{\"operation_id\":\"delete-fixture\"}}'\n" + " exit 0\n" + "fi\n" + "exit 2\n", + encoding="utf-8", + ) + wrapper.chmod(0o755) + append_fixture_ledger(worktree, short_id) + return { + "base": base, + "primary": primary, + "worktree": worktree, + "branch": branch, + "target": target, + "task": task, + "task_file": task_file, + "kent": kent, + "wrapper": wrapper, + "moved": moved, + "session_id": session_id, + "notes": notes, + "notes_path": f".todo/{short_id}/release-notes-ru.md", + "env": { + **os.environ, + "HOME": str(base / "home"), + "PYTHONDONTWRITEBYTECODE": "1", + "KENT_ENGINEERING_KIT_ROOT": str(KIT), + "PUBER_KENT_BIN": str(kent), + "PUBER_KENT_WORKTREE_BIN": str(wrapper), + "KENT_WORKTREE_WRAPPER": str(wrapper), + "PUBER_FAKE_TASK": str(task_file), + }, + } + + with tempfile.TemporaryDirectory() as directory: root = Path(directory) primary = root / "home" / "dev" / "android" / "Puber" @@ -73,16 +237,12 @@ with tempfile.TemporaryDirectory() as directory: target = managed / relative target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, target) - carrier_notes_path = managed / ".todo/PUB-99/release-notes-ru.md" - carrier_notes_path.parent.mkdir(parents=True, exist_ok=True) carrier_body = "puber_release_notes_v1\n\n# Изменения\n\n- Проверка релиза.\n".encode("utf-8") - carrier_notes_path.write_bytes(carrier_body) - carrier_notes_path.chmod(0o600) - carrier_notes_path = managed / ".todo/PUB-99/release-notes-ru.md" - carrier_notes_path.parent.mkdir(parents=True, exist_ok=True) - carrier_body = "puber_release_notes_v1\n\n# Изменения\n\n- Проверка релиза.\n".encode("utf-8") - carrier_notes_path.write_bytes(carrier_body) - carrier_notes_path.chmod(0o600) + for short_id in ("PUB-99", "PUB-100", "PUB-101", "PUB-102", "PUB-103", "PUB-104"): + path = managed / f".todo/{short_id}/release-notes-ru.md" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(carrier_body) + path.chmod(0o600) sh(["git", "config", "user.email", "test@example.invalid"], managed) sh(["git", "config", "user.name", "Puber Test"], managed) sh(["git", "add", "."], managed) @@ -110,6 +270,7 @@ with tempfile.TemporaryDirectory() as directory: }, }), ) + append_fixture_ledger(managed, "UNRELATED") graph = json.loads((managed / ".kent/workflows/puber-release.json").read_text(encoding="utf-8")) cleanup_node = next(node for node in graph["nodes"] if node["key"] == "cleanup") @@ -215,33 +376,18 @@ with tempfile.TemporaryDirectory() as directory: else: raise AssertionError("cleanup selected-run carrier mismatch accepted") - notes_path = managed / ".todo/PUB-99/release-notes-ru.md" - notes_path.parent.mkdir(parents=True, exist_ok=True) - notes_body = "puber_release_notes_v1\n\n# Изменения\n\n- Проверка релиза.\n".encode("utf-8") - notes_path.write_bytes(notes_body) - notes_path.chmod(0o600) - notes = {"schema": "puber_release_notes_v1", "path": ".todo/PUB-99/release-notes-ru.md", "mode": 0o600, "bytes": len(notes_body), "sha256": hashlib.sha256(notes_body).hexdigest(), "encoding": "UTF-8", "line_endings": "LF", "final_newline": True, "producer": ".kent/scripts/workflow-puber-release-publish", "previous_tag": "", "range": target, "target_commit": target} - publication, publication_digest = cleanup_mod.helper.publication_report("b" * 64, "https://github.com/rovkinmax/Puber/pull/7", "99.0.0", "v99.0.0", target, notes, tag_phase="tag_published", tag_status="already_present_exact", unresolved=True) - apk_name = "puber-v99.0.0.apk" - checksum_name = apk_name + ".sha256" - assets = [{"id": 1, "name": apk_name, "size": 4, "browser_download_url": "https://example.invalid/apk", "download_sha256": "a" * 64}, {"id": 2, "name": checksum_name, "size": 70, "browser_download_url": "https://example.invalid/checksum", "download_sha256": "b" * 64}] - release_value = {"schema": "puber_release_report_v1", "observation_operation_digest": "c" * 64, "observation_provenance_fingerprint": "d" * 64, "run": {"schema": "puber_release_run_v1", "state": "selected", "repository": "rovkinmax/Puber", "workflow_path": ".github/workflows/release.yml", "workflow_name": "Release", "event": "push", "run_id": 42, "attempt": 1, "head_sha": target, "ref": "refs/tags/v99.0.0", "head_branch": "v99.0.0"}, "jobs": {"run": {"run_id": 42, "attempt": 1, "path": ".github/workflows/release.yml", "name": "Release", "event": "push", "head_sha": target, "head_branch": "v99.0.0", "status": "completed", "conclusion": "success"}, "job": {"id": 777, "name": "Build & Publish", "status": "completed", "conclusion": "success", "attempt": 1}}, "release": {"tag_name": "v99.0.0", "target_commitish": "master", "name": "Puber v99.0.0", "draft": False, "prerelease": False, "html_url": "https://github.com/rovkinmax/Puber/releases/tag/v99.0.0", "body": notes_body.decode(), "remote_tag_target": target}, "assets": assets, "checksum": {"schema": "puber_checksum_proof_v1", "apk_asset_id": 1, "apk_name": apk_name, "apk_size": 4, "apk_download_sha256": "a" * 64, "checksum_asset_id": 2, "checksum_name": checksum_name, "checksum_size": 70, "checksum_download_sha256": "b" * 64, "parsed_checksum_digest": "a" * 64, "parsed_checksum_filename": apk_name, "download_sizes_match": True, "apk_checksum_equal": True}, "notes_digest": notes["sha256"], "publication_report_digest": publication_digest, "unresolved": False} - release_report = cleanup_mod.helper.canonical_json(release_value) - release_digest = hashlib.sha256(release_report.encode()).hexdigest() - cleanup_mod.ROOT = managed - valid_carrier = {"publication_report": publication, "publication_report_digest": publication_digest, "release_report": release_report, "release_report_digest": release_digest, "release_notes_path": notes["path"], "release_notes_digest": notes["sha256"]} - assert cleanup_mod._validate_carriers({"task_short_id": "PUB-99"}, valid_carrier)[0] is not None - broken_carrier = dict(valid_carrier) - broken_value = json.loads(release_report) - broken_value["run"]["head_sha"] = "e" * 40 - broken_carrier["release_report"] = cleanup_mod.helper.canonical_json(broken_value) - broken_carrier["release_report_digest"] = hashlib.sha256(broken_carrier["release_report"].encode()).hexdigest() - try: - cleanup_mod._validate_carriers({"task_short_id": "PUB-99"}, broken_carrier) - except cleanup_mod.helper.ContractError: - pass - else: - raise AssertionError("cleanup selected-run carrier mismatch accepted") + def make_carrier(short_id: str, mutation: str | None = None) -> dict: + notes = dict(carrier_notes) + notes["path"] = f".todo/{short_id}/release-notes-ru.md" + publication_value, publication_value_digest = cleanup_mod.helper.publication_report("b" * 64, "https://github.com/rovkinmax/Puber/pull/7", "99.0.0", "v99.0.0", target, notes, tag_phase="tag_published", tag_status="already_present_exact", unresolved=True) + value = json.loads(release_report) + value["publication_report_digest"] = publication_value_digest + value["notes_digest"] = notes["sha256"] + if mutation: + value["run"][mutation] = ("f" * 40 if mutation == "head_sha" else "v99.9.9" if mutation == "head_branch" else "refs/tags/v99.9.9") + encoded = cleanup_mod.helper.canonical_json(value) + return {"publication_report": publication_value, "publication_report_digest": publication_value_digest, "release_report": encoded, "release_report_digest": hashlib.sha256(encoded.encode()).hexdigest(), "release_notes_path": notes["path"], "release_notes_digest": notes["sha256"], "release_run": value["run"]} + conflict_seal = subprocess.run( [str(managed / ".kent/scripts/workflow-evidence-ledger"), "seal", "--task", "PUB-99", "--workspace", str(managed)], cwd=managed, @@ -327,6 +473,109 @@ with tempfile.TemporaryDirectory() as directory: assert third_janitor_output["transition"] == "task_janitor_blocked" assert "still targets the task worktree" not in third_janitor_output.get("blocker_reason", "") + # Each cleanup edge below gets an independent managed worktree and runtime + # ledger. The Kit intentionally treats a terminal cleanup tombstone as a + # workspace-wide conflict, so reusing PUB-99 would not be a valid fixture. + with tempfile.TemporaryDirectory(dir=root) as edge_directory: + edge = make_edge_fixture(Path(edge_directory), "PUB-100", "100.0.0", cleanup_node) + close_payload = { + "_kent": {"task_id": edge["task"]["summary"]["id"], "node_id": cleanup_node["id"], "transition_branch_key": "merge_watch_close_without_merge"}, + "workspace_path": str(edge["worktree"]), + "operation_id": "a" * 64, + "pr_url": "https://github.com/rovkinmax/Puber/pull/7", + "branch_name": edge["branch"], + "merge_strategy": "auto", + "pr_head_oid": "c" * 40, + "pr_base_oid": "b" * 40, + } + close_result = subprocess.run([str(edge["worktree"] / ".kent/scripts/workflow-release-cleanup")], cwd=edge["worktree"], input=json.dumps(close_payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=edge["env"]) + assert close_result.returncode == 0, close_result.stderr + close_output = json.loads(close_result.stdout) + assert close_output["transition"] == "cleanup_task_janitor" + close_janitor = subprocess.run([str(edge["worktree"] / ".kent/scripts/workflow-task-janitor")], cwd=edge["worktree"], input=json.dumps({"workspace_path": str(edge["worktree"]), "branch_name": edge["branch"], "pr_url": close_payload["pr_url"], "cleanup_mode": "report_only", "cleanup_session_id": edge["session_id"], "cleanup_report": close_output["cleanup_report"], "task_short_id": "PUB-100"}), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=edge["env"]) + assert close_janitor.returncode == 0, close_janitor.stderr + assert json.loads(close_janitor.stdout)["transition"] == "task_janitor_blocked" + + def release_carrier_for(edge: dict) -> dict: + version = edge["task"]["body"].split("release_version: ", 1)[1].splitlines()[0] + tag = f"v{version}" + notes = { + "schema": "puber_release_notes_v1", + "path": edge["notes_path"], + "mode": 0o600, + "bytes": len(edge["notes"]), + "sha256": hashlib.sha256(edge["notes"]).hexdigest(), + "encoding": "UTF-8", + "line_endings": "LF", + "final_newline": True, + "producer": ".kent/scripts/workflow-puber-release-publish", + "previous_tag": "", + "range": edge["target"], + "target_commit": edge["target"], + } + publication, publication_digest = cleanup_mod.helper.publication_report("b" * 64, "https://github.com/rovkinmax/Puber/pull/7", version, tag, edge["target"], notes, tag_phase="tag_published", tag_status="already_present_exact", unresolved=True) + value = json.loads(release_report) + value["publication_report_digest"] = publication_digest + value["notes_digest"] = notes["sha256"] + value["run"].update({"head_sha": edge["target"], "ref": f"refs/tags/{tag}", "head_branch": tag}) + value["jobs"]["run"].update({"head_sha": edge["target"], "head_branch": tag}) + value["release"].update({"tag_name": tag, "name": f"Puber {tag}", "html_url": f"https://github.com/rovkinmax/Puber/releases/tag/{tag}", "remote_tag_target": edge["target"]}) + for asset in value["assets"]: + if asset["name"].endswith(".sha256"): + asset["name"] = f"puber-{tag}.apk.sha256" + else: + asset["name"] = f"puber-{tag}.apk" + apk_asset = next(asset for asset in value["assets"] if asset["name"].endswith(".apk")) + checksum_asset = next(asset for asset in value["assets"] if asset["name"].endswith(".apk.sha256")) + value["checksum"].update({ + "apk_asset_id": apk_asset["id"], + "apk_name": apk_asset["name"], + "apk_size": apk_asset["size"], + "checksum_asset_id": checksum_asset["id"], + "checksum_name": checksum_asset["name"], + "checksum_size": checksum_asset["size"], + "parsed_checksum_filename": apk_asset["name"], + }) + encoded = cleanup_mod.helper.canonical_json(value) + return { + "publication_report": publication, + "publication_report_digest": publication_digest, + "release_report": encoded, + "release_report_digest": hashlib.sha256(encoded.encode()).hexdigest(), + "release_notes_path": edge["notes_path"], + "release_notes_digest": notes["sha256"], + "release_run": cleanup_mod.helper.canonical_json(value["run"]), + "_value": value, + } + + with tempfile.TemporaryDirectory(dir=root) as edge_directory: + edge = make_edge_fixture(Path(edge_directory), "PUB-101", "101.0.0", cleanup_node) + carrier = release_carrier_for(edge) + release_payload = {"_kent": {"task_id": edge["task"]["summary"]["id"], "node_id": cleanup_node["id"], "transition_branch_key": "release_release_published"}, **{key: value for key, value in carrier.items() if not key.startswith("_")}} + release_result = subprocess.run([str(edge["worktree"] / ".kent/scripts/workflow-release-cleanup")], cwd=edge["worktree"], input=json.dumps(release_payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=edge["env"]) + assert release_result.returncode == 0, release_result.stderr + release_output = json.loads(release_result.stdout) + assert release_output["transition"] == "cleanup_task_janitor" + release_janitor = subprocess.run([str(edge["worktree"] / ".kent/scripts/workflow-task-janitor")], cwd=edge["worktree"], input=json.dumps({"workspace_path": str(edge["worktree"]), "branch_name": edge["branch"], "pr_url": "https://github.com/rovkinmax/Puber/pull/7", "cleanup_mode": "report_only", "cleanup_session_id": edge["session_id"], "cleanup_report": release_output["cleanup_report"], "task_short_id": "PUB-101"}), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=edge["env"]) + assert release_janitor.returncode == 0, release_janitor.stderr + assert json.loads(release_janitor.stdout)["transition"] == "task_janitor_blocked" + + for index, mutation in enumerate(("head_sha", "head_branch", "ref"), start=102): + with tempfile.TemporaryDirectory(dir=root) as edge_directory: + edge = make_edge_fixture(Path(edge_directory), f"PUB-{index}", f"{index}.0.0", cleanup_node) + carrier = release_carrier_for(edge) + value = json.loads(carrier["release_report"]) + value["run"][mutation] = "f" * 40 if mutation == "head_sha" else f"v{index}.9.9" if mutation == "head_branch" else f"refs/tags/v{index}.9.9" + invalid_report = cleanup_mod.helper.canonical_json(value) + invalid = {**carrier, "release_report": invalid_report, "release_report_digest": hashlib.sha256(invalid_report.encode()).hexdigest(), "release_run": cleanup_mod.helper.canonical_json(value["run"])} + invalid_payload = {"_kent": {"task_id": edge["task"]["summary"]["id"], "node_id": cleanup_node["id"], "transition_branch_key": "release_release_published"}, **{key: value for key, value in invalid.items() if not key.startswith("_")}} + invalid_result = subprocess.run([str(edge["worktree"] / ".kent/scripts/workflow-release-cleanup")], cwd=edge["worktree"], input=json.dumps(invalid_payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=edge["env"]) + assert invalid_result.returncode != 0 and invalid_result.stdout == "", (f"PUB-{index}", invalid_result.stderr) + + task["summary"]["short_id"] = "PUB-99" + task["live_sessions"] = [] + task_file.write_text(json.dumps(task), encoding="utf-8") + moved.touch() tampered = initial_lines[0][:-1] + "0\n" + initial_lines[1] + "\nJanitor tampered" expect_rejected([str(managed / ".kent/scripts/workflow-release-cleanup")], managed, {**retry, "cleanup_report": tampered}, env, "tampered canonical line") expect_rejected([str(managed / ".kent/scripts/workflow-release-cleanup")], managed, {**retry, "cleanup_report": initial_lines[0] + "\n" + initial_lines[1][:-1] + "0\nJanitor tampered"}, env, "tampered marker") @@ -340,4 +589,4 @@ with tempfile.TemporaryDirectory() as directory: expect_rejected([str(managed / ".kent/scripts/workflow-release-cleanup")], managed, {**retry, "cleanup_report": initial_lines[0] + "\n" + marker_hash_line + "\nJanitor tampered"}, env, "tampered marker hash") expect_rejected([str(managed / ".kent/scripts/workflow-release-cleanup")], managed, {**retry, "cleanup_report": initial_report + "\nTERMINAL_EVIDENCE_V1 extra"}, env, "extra terminal marker") -print("workflow-release-cleanup: passed scenarios=initial_seal,release_carrier_success,close_without_merge_entry,janitor_block,leave_retry,janitor_later_boundary,tampered_line,marker,marker_task,marker_hash,extra_marker") +print("workflow-release-cleanup: passed scenarios=cancel_edge,close_without_merge_edge,release_carrier_edge,selected_run_head_sha_branch_ref_boundary,janitor_active_block,post_leave_block,third_retry_block,marker_adversarial,unrelated_ledger") diff --git a/.kent/scripts/workflow-release-cleanup b/.kent/scripts/workflow-release-cleanup index fbdb95a8..6551766f 100755 --- a/.kent/scripts/workflow-release-cleanup +++ b/.kent/scripts/workflow-release-cleanup @@ -200,6 +200,7 @@ def _cleanup_report(state: dict, session: str, session_identity: dict[str, str], if marker["redaction"]["report_sha256"] != helper.sha256_bytes(encoded.encode("utf-8")): raise helper.ContractError("terminal evidence redaction digest does not match the canonical report line") kit["validate_cleanup_report"](encoded + "\n" + marker_line) + _validate_terminal_marker_ledger(state["task_short_id"], marker, kit) except helper.ContractError: raise except Exception as error: diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json index 82ed81af..202cbd73 100644 --- a/.kent/workflows/puber-release.manifest.json +++ b/.kent/workflows/puber-release.manifest.json @@ -71,7 +71,7 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-release-cleanup=eab3e55e97f30bd8d727edb0a048d1b6e713b1fa1eaa5a699f99cd28bf4bb5a5", + "key": ".kent/scripts/tests/test-workflow-release-cleanup=94edc3d2f1381198f6f4c855f6874cdf62deb05e3e969de2dc136a371187d7bc", "runtime_digest_required": true }, { @@ -91,7 +91,7 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-release-cleanup=2324f7cac3fc7cc07828731214f8566173807ae2da3e16e41ee61fde561cba8c", + "key": ".kent/scripts/workflow-release-cleanup=00809921011264166af686b5cefddab84dee12d783531fa50a5bc837a6be287e", "runtime_digest_required": true }, { From 332957de0302fea29d3eff27fb67ed4114369aa4 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 08:44:38 +0500 Subject: [PATCH 15/21] Close final release runtime boundaries --- .../tests/test-workflow-release-cleanup | 178 +++++++++++++++++- .../tests/test-workflow-wait-github-release | 55 +++++- .kent/scripts/workflow-puber-release-intent | 139 ++++++++++---- .kent/scripts/workflow-release-cleanup | 90 +++++++-- .kent/workflows/puber-release.manifest.json | 8 +- 5 files changed, 407 insertions(+), 63 deletions(-) diff --git a/.kent/scripts/tests/test-workflow-release-cleanup b/.kent/scripts/tests/test-workflow-release-cleanup index 96f79626..ca7c4750 100755 --- a/.kent/scripts/tests/test-workflow-release-cleanup +++ b/.kent/scripts/tests/test-workflow-release-cleanup @@ -86,6 +86,7 @@ def make_edge_fixture(base: Path, short_id: str, version: str, cleanup_node: dic ".kent/scripts/workflow-release-cleanup", ".kent/scripts/workflow-task-janitor", ".kent/scripts/tests/test-workflow-release-cleanup", + ".kent/scripts/tests/test-workflow-wait-github-release", ".kent/scripts/workflow_runtime_contracts.py", ".kent/workflows/puber-release.json", ".kent/workflows/puber-release.manifest.json", @@ -332,6 +333,95 @@ with tempfile.TemporaryDirectory() as directory: "KENT_WORKTREE_WRAPPER": str(wrapper), "PUBER_FAKE_TASK": str(task_file), } + cleanup_mod.ROOT = managed + active_status = { + "target": {"WorkspaceID": workspace_id, "WorkspaceName": "Puber", "WorkspaceRoot": str(primary), "WorkspaceAvailability": "available", "Worktree": {"ID": "worktree-fixture", "Name": "PUB-99", "Root": str(managed), "Availability": "available"}, "CwdRelpath": ".", "EffectiveWorkdir": str(managed)}, + "worktree": {"recorded_root": str(managed), "observed_root": str(managed), "display_name": "PUB-99", "recorded_branch_ref": "refs/heads/release/99.0.0"}, + "problems": [], + } + primary_status = { + "target": {"WorkspaceID": workspace_id, "WorkspaceName": "Puber", "WorkspaceRoot": str(primary), "WorkspaceAvailability": "available", "Worktree": None, "CwdRelpath": ".", "EffectiveWorkdir": str(primary)}, + "worktree": {"recorded_root": str(primary), "observed_root": str(primary)}, + "problems": [], + } + status_calls = [] + original_status = cleanup_mod.helper.session_worktree_status + cleanup_mod.helper.session_worktree_status = lambda _root, session: (status_calls.append(session) or json.loads(json.dumps(primary_status if session == "session-primary" else active_status))) + selection_state = {"task": {"live_sessions": [{"session_id": "session-primary", "session_name": "Primary", "node_display_name": "primary"}, {"session_id": session_id, "session_name": "Cleanup", "node_display_name": "cleanup"}]}} + dirty_primary = primary / "unrelated-primary-edit" + dirty_primary.write_text("unrelated", encoding="utf-8") + selected_session, selected_identity = cleanup_mod._eligible_session(selection_state) + assert selected_session == session_id and selected_identity["worktree_root"] == str(managed.resolve()) + assert status_calls == ["session-primary", session_id] + assert cleanup_mod.helper.session_targets_worktree(managed, "session-primary") is False + assert status_calls == ["session-primary", session_id, "session-primary"] + status_calls.clear() + cleanup_mod.helper.session_worktree_status = lambda _root, session: (status_calls.append(session) or json.loads(json.dumps(primary_status))) + retry_session, retry_identity = cleanup_mod._session_for_cleanup({}, session_id, selected_identity) + assert retry_session == session_id and retry_identity == selected_identity and status_calls == [session_id] + dirty_primary.unlink() + cleanup_mod.helper.session_worktree_status = original_status + + def expect_status_contract(label: str, payload: object) -> None: + original = cleanup_mod.helper.session_worktree_status + cleanup_mod.helper.session_worktree_status = lambda _root, _session: payload + try: + cleanup_mod.helper.session_targets_worktree(managed, "status-probe") + except cleanup_mod.helper.ContractError: + pass + else: + raise AssertionError(label) + finally: + cleanup_mod.helper.session_worktree_status = original + + malformed_active = json.loads(json.dumps(active_status)) + malformed_active["target"]["Worktree"].pop("ID") + expect_status_contract("malformed active status", malformed_active) + for label, mutation in ( + ("wrong root", lambda value: value["target"].__setitem__("EffectiveWorkdir", str(primary))), + ("wrong branch", lambda value: value["worktree"].__setitem__("recorded_branch_ref", "refs/heads/foreign")), + ("wrong display", lambda value: value["worktree"].__setitem__("display_name", "foreign")), + ("foreign Workspace", lambda value: value["target"].__setitem__("WorkspaceRoot", str(root / "foreign-primary"))), + ): + candidate = json.loads(json.dumps(active_status)) + mutation(candidate) + expect_status_contract(label, candidate) + expect_status_contract("non-object status", []) + original_bin = cleanup_mod.helper._kent_worktree_bin + cleanup_mod.helper._kent_worktree_bin = lambda _root=None: str(root / "missing-kent-worktree") + try: + try: + cleanup_mod.helper.session_targets_worktree(managed, "missing-wrapper") + except cleanup_mod.helper.ContractError: + pass + else: + raise AssertionError("missing wrapper status was accepted") + finally: + cleanup_mod.helper._kent_worktree_bin = original_bin + duplicate_state = {"task": {"live_sessions": [{"session_id": "a", "session_name": "A", "node_display_name": "cleanup"}, {"session_id": "b", "session_name": "B", "node_display_name": "cleanup"}]}} + original = cleanup_mod.helper.session_worktree_status + cleanup_mod.helper.session_worktree_status = lambda _root, _session: json.loads(json.dumps(active_status)) + try: + try: + cleanup_mod._eligible_session(duplicate_state) + except cleanup_mod.helper.ContractError: + pass + else: + raise AssertionError("duplicate active Sessions were accepted") + finally: + cleanup_mod.helper.session_worktree_status = original + no_active_state = {"task": {"live_sessions": [{"session_id": "primary", "session_name": "P", "node_display_name": "cleanup"}]}} + cleanup_mod.helper.session_worktree_status = lambda _root, _session: json.loads(json.dumps(primary_status)) + try: + try: + cleanup_mod._eligible_session(no_active_state) + except cleanup_mod.helper.ContractError: + pass + else: + raise AssertionError("no active Session was accepted") + finally: + cleanup_mod.helper.session_worktree_status = original + cancel = { "_kent": {"task_id": task_id, "node_id": cleanup_node["id"], "transition_branch_key": "release_cancel_cleanup"}, "pr_url": "", @@ -352,6 +442,61 @@ with tempfile.TemporaryDirectory() as directory: assert marker["redaction"]["report_sha256"] == hashlib.sha256(initial_lines[0].encode()).hexdigest() ledger = managed / ".kent/runtime/PUB-99/evidence-ledger.jsonl" assert marker["final_hash"] == json.loads(ledger.read_text().splitlines()[-1])["event_hash"] + os.environ["KENT_ENGINEERING_KIT_ROOT"] = str(KIT) + ledger_kit = cleanup_mod.helper._kit_modules() + cleanup_mod._validate_terminal_marker_ledger("PUB-99", marker, ledger_kit) + runtime_root = managed / ".kent/runtime" + expected_tombstone = runtime_root / f".evidence-cleanup-{ledger_kit['canonical_sha256'](marker)}" + + def expect_ledger_rejected(label: str) -> None: + try: + cleanup_mod._validate_terminal_marker_ledger("PUB-99", marker, ledger_kit) + except cleanup_mod.helper.ContractError: + return + raise AssertionError(label) + + wrong_tombstone = runtime_root / (".evidence-cleanup-" + "0" * 64) + wrong_tombstone.mkdir() + shutil.copy2(ledger, wrong_tombstone / "evidence-ledger.jsonl") + expect_ledger_rejected("wrong cleanup tombstone") + shutil.rmtree(wrong_tombstone) + shutil.copytree(runtime_root / "PUB-99", expected_tombstone) + expect_ledger_rejected("active ledger and exact tombstone both present") + shutil.rmtree(expected_tombstone) + active_dir = runtime_root / "PUB-99" + active_backup = runtime_root / "PUB-99.active-backup" + active_dir.rename(active_backup) + expect_ledger_rejected("neither active ledger nor tombstone") + active_backup.rename(active_dir) + ledger_bytes = ledger.read_bytes() + ledger.write_bytes(b"{malformed\n") + expect_ledger_rejected("malformed ledger") + ledger.write_bytes(ledger_bytes) + wrong_task_dir = runtime_root / "PUB-100" + wrong_task_dir.mkdir() + shutil.copy2(ledger, wrong_task_dir / "evidence-ledger.jsonl") + try: + cleanup_mod._validate_terminal_marker_ledger("PUB-100", marker, ledger_kit) + except cleanup_mod.helper.ContractError: + pass + else: + raise AssertionError("wrong task ledger accepted") + shutil.rmtree(wrong_task_dir) + + def reject_symlink_component(path: Path, label: str) -> None: + backup = path.with_name(path.name + ".symlink-backup") + path.rename(backup) + path.symlink_to(backup.name, target_is_directory=backup.is_dir()) + try: + expect_ledger_rejected(label) + finally: + path.unlink() + backup.rename(path) + + reject_symlink_component(managed / ".kent", ".kent symlink") + reject_symlink_component(runtime_root, "runtime symlink") + reject_symlink_component(active_dir, "task directory symlink") + reject_symlink_component(ledger, "ledger symlink") cleanup_mod.ROOT = managed carrier_notes = {"schema": "puber_release_notes_v1", "path": ".todo/PUB-99/release-notes-ru.md", "mode": 0o600, "bytes": len(carrier_body), "sha256": hashlib.sha256(carrier_body).hexdigest(), "encoding": "UTF-8", "line_endings": "LF", "final_newline": True, "producer": ".kent/scripts/workflow-puber-release-publish", "previous_tag": "", "range": target, "target_commit": target} @@ -414,6 +559,21 @@ with tempfile.TemporaryDirectory() as directory: assert "kent worktree leave" in first_output["blocker_reason"] assert first_output["cleanup_report"].splitlines()[:2] == initial_lines assert managed.exists() and (primary / ".git").exists() + assert not active_dir.exists() and expected_tombstone.is_dir() + cleanup_mod._validate_terminal_marker_ledger("PUB-99", marker, ledger_kit) + tombstone_backup = expected_tombstone.with_name(expected_tombstone.name + ".backup") + expected_tombstone.rename(tombstone_backup) + expected_tombstone.symlink_to(tombstone_backup.name, target_is_directory=True) + expect_ledger_rejected("tombstone directory symlink") + expected_tombstone.unlink() + tombstone_backup.rename(expected_tombstone) + tombstone_ledger = expected_tombstone / "evidence-ledger.jsonl" + tombstone_ledger_backup = expected_tombstone / "evidence-ledger.jsonl.backup" + tombstone_ledger.rename(tombstone_ledger_backup) + tombstone_ledger.symlink_to(tombstone_ledger_backup.name) + expect_ledger_rejected("tombstone ledger symlink") + tombstone_ledger.unlink() + tombstone_ledger_backup.rename(tombstone_ledger) task["live_sessions"] = [] task_file.write_text(json.dumps(task), encoding="utf-8") @@ -426,6 +586,8 @@ with tempfile.TemporaryDirectory() as directory: "cleanup_mode": "report_only", "cleanup_report": first_output["cleanup_report"], } + multiline_report = initial_lines[0] + "\nJanitor first blocker\n command detail: session still owns worktree\n status detail: leave required\n" + initial_lines[1] + retry["cleanup_report"] = multiline_report retry_result = subprocess.run([str(managed / ".kent/scripts/workflow-release-cleanup")], cwd=managed, input=json.dumps(retry), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) assert retry_result.returncode == 0, retry_result.stderr retry_output = json.loads(retry_result.stdout) @@ -433,6 +595,7 @@ with tempfile.TemporaryDirectory() as directory: assert retry_output["cleanup_report"] != first_output["cleanup_report"] retry_lines = retry_output["cleanup_report"].splitlines() assert retry_lines[0] == initial_lines[0] and retry_lines[-1] == initial_lines[1] + assert retry_lines[1:-1] == ["Janitor first blocker", " command detail: session still owns worktree", " status detail: leave required"] assert json.loads(retry_output["cleanup_report"].splitlines()[0]) == initial_json second_janitor = subprocess.run( @@ -472,6 +635,11 @@ with tempfile.TemporaryDirectory() as directory: third_janitor_output = json.loads(third_janitor.stdout) assert third_janitor_output["transition"] == "task_janitor_blocked" assert "still targets the task worktree" not in third_janitor_output.get("blocker_reason", "") + fourth_retry = {**retry, "cleanup_report": third_janitor_output["cleanup_report"]} + fourth_result = subprocess.run([str(managed / ".kent/scripts/workflow-release-cleanup")], cwd=managed, input=json.dumps(fourth_retry), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert fourth_result.returncode == 0, fourth_result.stderr + fourth_output = json.loads(fourth_result.stdout) + assert fourth_output["cleanup_report"].splitlines()[-1] == initial_lines[1] # Each cleanup edge below gets an independent managed worktree and runtime # ledger. The Kit intentionally treats a terminal cleanup tombstone as a @@ -556,6 +724,14 @@ with tempfile.TemporaryDirectory() as directory: assert release_result.returncode == 0, release_result.stderr release_output = json.loads(release_result.stdout) assert release_output["transition"] == "cleanup_task_janitor" + for label, mutation in ( + ("outer release_run missing", lambda value: value.pop("release_run")), + ("outer release_run not selected", lambda value: value.__setitem__("release_run", cleanup_mod.helper.canonical_json(cleanup_mod.helper.NOT_SELECTED_RUN))), + ("outer release_run mismatched", lambda value: value.__setitem__("release_run", cleanup_mod.helper.canonical_json({**json.loads(value["release_run"]), "head_sha": "f" * 40}))), + ): + invalid_payload = dict(release_payload) + mutation(invalid_payload) + expect_rejected([str(edge["worktree"] / ".kent/scripts/workflow-release-cleanup")], edge["worktree"], invalid_payload, edge["env"], label) release_janitor = subprocess.run([str(edge["worktree"] / ".kent/scripts/workflow-task-janitor")], cwd=edge["worktree"], input=json.dumps({"workspace_path": str(edge["worktree"]), "branch_name": edge["branch"], "pr_url": "https://github.com/rovkinmax/Puber/pull/7", "cleanup_mode": "report_only", "cleanup_session_id": edge["session_id"], "cleanup_report": release_output["cleanup_report"], "task_short_id": "PUB-101"}), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=edge["env"]) assert release_janitor.returncode == 0, release_janitor.stderr assert json.loads(release_janitor.stdout)["transition"] == "task_janitor_blocked" @@ -589,4 +765,4 @@ with tempfile.TemporaryDirectory() as directory: expect_rejected([str(managed / ".kent/scripts/workflow-release-cleanup")], managed, {**retry, "cleanup_report": initial_lines[0] + "\n" + marker_hash_line + "\nJanitor tampered"}, env, "tampered marker hash") expect_rejected([str(managed / ".kent/scripts/workflow-release-cleanup")], managed, {**retry, "cleanup_report": initial_report + "\nTERMINAL_EVIDENCE_V1 extra"}, env, "extra terminal marker") -print("workflow-release-cleanup: passed scenarios=cancel_edge,close_without_merge_edge,release_carrier_edge,selected_run_head_sha_branch_ref_boundary,janitor_active_block,post_leave_block,third_retry_block,marker_adversarial,unrelated_ledger") +print("workflow-release-cleanup: passed scenarios=one_read_session_selection,malformed_status_matrix,dirty_primary,ledger_active_tombstone_safety,cancel_edge,close_without_merge_edge,release_carrier_edge,outer_release_run_binding,selected_run_head_sha_branch_ref_boundary,janitor_active_block,multiline_repeated_retries,post_leave_block,third_retry_block,marker_adversarial,unrelated_ledger") diff --git a/.kent/scripts/tests/test-workflow-wait-github-release b/.kent/scripts/tests/test-workflow-wait-github-release index eb538479..1a98503e 100755 --- a/.kent/scripts/tests/test-workflow-wait-github-release +++ b/.kent/scripts/tests/test-workflow-wait-github-release @@ -46,8 +46,8 @@ args=sys.argv[1:]; target=os.environ["PUBER_TARGET"]; tag="v99.0.0"; mode=os.env with open(os.environ["PUBER_CALLS"], "a", encoding="utf-8") as calls: calls.write(json.dumps(args)+"\\n") if args[:2] == ["release","edit"]: body=sys.stdin.read() - if mode != "edit_mismatch": open(os.environ["PUBER_BODY"],"w",encoding="utf-8").write(body) - sys.exit(1 if mode == "edit_lost_ack" else 0) + if mode not in ("edit_mismatch","edit_failure"): open(os.environ["PUBER_BODY"],"w",encoding="utf-8").write(body) + sys.exit(1 if mode in ("edit_lost_ack","edit_failure") else 0) if args[0] != "api": sys.exit(2) endpoint=args[1] if endpoint.endswith("/git/ref/heads/master"): @@ -75,15 +75,31 @@ if "/actions/runs/42/attempts/1/jobs" in endpoint: print(json.dumps({"jobs":[{"id":777,"name":"Build & Publish","status":"completed","conclusion":"success"}]})); sys.exit(0) if "/actions/runs/42/attempts/1" in endpoint: if mode == "block": sys.exit(9) - if mode == "failed_run": print(json.dumps({"id":42,"run_attempt":1,"path":".github/workflows/release.yml","name":"Release","event":"push","head_sha":target,"head_branch":tag,"repository":{"full_name":"rovkinmax/Puber"},"status":"completed","conclusion":"failure"})); sys.exit(0) - print(json.dumps({"id":42,"run_attempt":1,"path":".github/workflows/release.yml","name":"Release","event":"push","head_sha":target,"head_branch":tag,"repository":{"full_name":"rovkinmax/Puber"},"status":"completed","conclusion":"success"})); sys.exit(0) + run_id=43 if mode == "switch_id" else 42 + run_attempt=2 if mode == "switch_attempt" else 1 + run_sha="b"*40 if mode == "switch_head_sha" else target + run_branch="v99.0.1" if mode == "switch_head_branch" else tag + run_conclusion="failure" if mode == "failed_run" else "success" + print(json.dumps({"id":run_id,"run_attempt":run_attempt,"path":".github/workflows/release.yml","name":"Release","event":"push","head_sha":run_sha,"head_branch":run_branch,"repository":{"full_name":"rovkinmax/Puber"},"status":"completed","conclusion":run_conclusion})); sys.exit(0) if "/releases/assets/1" in endpoint: + if mode == "invalid_utf8": sys.stdout.buffer.write(bytes((255,254))); sys.exit(0) sys.stdout.buffer.write(open(os.environ["PUBER_APK"],"rb").read()); sys.exit(0) if "/releases/assets/2" in endpoint: + if mode == "invalid_utf8": sys.stdout.buffer.write(bytes((255,254))); sys.exit(0) + if mode == "checksum_digest_mismatch": + sys.stdout.write("0"*64 + " puber-v99.0.0.apk\\n"); sys.exit(0) sys.stdout.buffer.write(open(os.environ["PUBER_CHECKSUM"],"rb").read()); sys.exit(0) if "/releases/tags/" in endpoint: apk=open(os.environ["PUBER_APK"],"rb").read(); checksum=open(os.environ["PUBER_CHECKSUM"],"rb").read() - print(json.dumps({"tag_name":tag,"target_commitish":"master","name":"Puber "+tag,"draft":False,"prerelease":False,"html_url":"https://github.com/rovkinmax/Puber/releases/tag/"+tag,"body":open(os.environ["PUBER_BODY"],encoding="utf-8").read(),"assets":[{"id":1,"name":"puber-"+tag+".apk","size":len(apk),"browser_download_url":"https://example.invalid/apk"},{"id":2,"name":"puber-"+tag+".apk.sha256","size":len(checksum),"browser_download_url":"https://example.invalid/checksum"}]})); sys.exit(0) + assets=[{"id":1,"name":"puber-"+tag+".apk","size":len(apk),"browser_download_url":"https://example.invalid/apk"},{"id":2,"name":"puber-"+tag+".apk.sha256","size":len(checksum),"browser_download_url":"https://example.invalid/checksum"}] + if mode == "missing_assets": assets=assets[:1] + if mode == "extra_assets": assets.append({"id":3,"name":"extra.txt","size":1,"browser_download_url":"https://example.invalid/extra"}) + if mode == "duplicate_asset_id": assets[1]["id"]=1 + if mode == "zero_asset_size": assets[0]["size"]=0 + if mode == "invalid_asset_size": assets[0]["size"]="invalid" + if mode == "download_size_mismatch": assets[0]["size"] += 1 + if mode == "checksum_filename_mismatch": assets[1]["name"]="wrong.sha256" + print(json.dumps({"tag_name":tag,"target_commitish":"master","name":"Puber "+tag,"draft":False,"prerelease":False,"html_url":"https://github.com/rovkinmax/Puber/releases/tag/"+tag,"body":open(os.environ["PUBER_BODY"],encoding="utf-8").read(),"assets":assets})); sys.exit(0) sys.exit(3) """, encoding="utf-8", @@ -143,9 +159,12 @@ sys.exit(3) rejected("remote tag mismatch", lambda: publish_fixture.helper.read_release(work, "v99.0.0", target)) sh(["git", "update-ref", "refs/tags/v99.0.0", target], Path(bare_path)) - checksum_file.write_bytes(b"\xff\xfe") - rejected("invalid UTF-8 checksum", lambda: publish_fixture.helper.read_release(work, "v99.0.0", target)) - checksum_file.write_text(hashlib.sha256(apk_file.read_bytes()).hexdigest() + " puber-v99.0.0.apk\n", encoding="utf-8") + for mode in ("missing_assets", "extra_assets", "duplicate_asset_id", "zero_asset_size", "invalid_asset_size", "download_size_mismatch", "checksum_filename_mismatch", "checksum_digest_mismatch", "invalid_utf8"): + os.environ["PUBER_MODE"] = mode + env["PUBER_MODE"] = mode + rejected(f"API release {mode}", lambda mode=mode: publish_fixture.helper.read_release(work, "v99.0.0", target)) + os.environ["PUBER_MODE"] = "normal" + env["PUBER_MODE"] = "normal" for mutation in ("asset_cardinality", "asset_id", "asset_size", "checksum_digest"): candidate = json.loads(json.dumps(release_report)) @@ -178,6 +197,19 @@ sys.exit(3) assert blocked["transition"] == "monitor_needs_user_action", blocked assert blocked["release_run"] != monitor_payload["release_run"] + pinned_payload = dict(monitor_payload) + pinned_payload["release_run"] = output["release_run"] + for mode in ("switch_id", "switch_attempt", "switch_head_sha", "switch_head_branch"): + calls_file.write_text("", encoding="utf-8") + os.environ["PUBER_MODE"] = mode + env["PUBER_MODE"] = mode + pinned_result = subprocess.run([str(work/".kent/scripts/workflow-wait-github-release")], cwd=work, input=json.dumps(pinned_payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert pinned_result.returncode == 0, pinned_result.stderr + pinned_block = json.loads(pinned_result.stdout) + assert pinned_block["transition"] == "monitor_needs_user_action" + assert pinned_block["release_run"] == pinned_payload["release_run"] + assert not any("/actions/runs?" in line for line in calls_file.read_text(encoding="utf-8").splitlines()) + retry = {"_kent": {**monitor_payload["_kent"], "transition_branch_key": "monitor_needs_user_action"}} retry.update({key: blocked[key] for key in ("blocker_reason", "workspace_path", "operation_id", "pr_url", "branch_name", "release_version", "release_tag", "target_commit", "tag_push_status", "release_notes_path", "publication_report", "publication_report_digest", "release_run")}) calls_file.write_text("", encoding="utf-8") @@ -199,4 +231,9 @@ sys.exit(3) body_file.write_text("", encoding="utf-8") mismatch = subprocess.run([str(work/".kent/scripts/workflow-wait-github-release")], cwd=work, input=json.dumps(monitor_payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) assert mismatch.returncode == 0 and json.loads(mismatch.stdout)["transition"] == "monitor_needs_user_action" -print("workflow-wait-github-release: passed scenarios=happy,selected_run_binding,run_job_rows,remote_tag,invalid_utf8,asset_checksum,body_correct,block_retry_no_discovery,edit_lost_ack,post_edit_readback") + + env["PUBER_MODE"] = "edit_failure" + body_file.write_text("", encoding="utf-8") + edit_failure = subprocess.run([str(work/".kent/scripts/workflow-wait-github-release")], cwd=work, input=json.dumps(monitor_payload), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + assert edit_failure.returncode == 0 and json.loads(edit_failure.stdout)["transition"] == "monitor_needs_user_action" +print("workflow-wait-github-release: passed scenarios=happy,pinned_switch_id_attempt_head_sha_head_branch,no_discovery_on_blocker,run_job_api_rows,release_asset_api_matrix,remote_tag,body_correct,edit_lost_ack,edit_failure,post_edit_readback") diff --git a/.kent/scripts/workflow-puber-release-intent b/.kent/scripts/workflow-puber-release-intent index b708d263..18ffe7a6 100755 --- a/.kent/scripts/workflow-puber-release-intent +++ b/.kent/scripts/workflow-puber-release-intent @@ -158,7 +158,10 @@ def session_worktree_status(root: Path, session_id: str) -> dict[str, Any]: session_id = require_string(session_id, "cleanup_session_id") if any(character.isspace() for character in session_id) or "/" in session_id or "\\" in session_id: raise ContractError("cleanup_session_id is not safe") - result = run([_kent_worktree_bin(), "status", "--session", session_id, "--json"], cwd=root, env=child_env(), check=False) + try: + result = run([_kent_worktree_bin(), "status", "--session", session_id, "--json"], cwd=root, env=child_env(), check=False) + except (OSError, TypeError, ValueError) as error: + raise ContractError("Kent worktree status failed") from error if result.returncode != 0: raise ContractError(result.stderr.strip() or result.stdout.strip() or "Kent worktree status failed") return _json_one(result.stdout.encode("utf-8")) @@ -167,20 +170,45 @@ def git_common_root(root: Path) -> Path: common = Path(git(root, "rev-parse", "--git-common-dir")) if not common.is_absolute(): common = root / common - common = common.expanduser().resolve() + try: + common = common.expanduser().resolve() + except (OSError, RuntimeError, TypeError, ValueError) as error: + raise ContractError("Git common directory is not a safe path") from error if common.name != ".git": raise ContractError("Git common directory is not a .git directory") primary = common.parent - if git(primary, "rev-parse", "--show-toplevel") != str(primary) or git(primary, "status", "--porcelain=v1", "--untracked-files=all"): - raise ContractError("primary checkout is not the exact clean Git root") + try: + top_level = git(primary, "rev-parse", "--show-toplevel") + except ContractError: + raise + if top_level != str(primary): + raise ContractError("primary checkout is not the exact Git root") return primary def current_branch_ref(root: Path) -> str: branch = require_string(git(root, "symbolic-ref", "--quiet", "--short", "HEAD"), "current branch") return f"refs/heads/{branch}" +def _resolved_status_path(value: Any, label: str) -> Path: + if not isinstance(value, str) or not value.strip(): + raise ContractError(f"{label} must be a non-empty path string") + try: + path = Path(value).expanduser() + if not path.is_absolute(): + raise ContractError(f"{label} must be absolute") + return path.resolve() + except ContractError: + raise + except (OSError, RuntimeError, TypeError, ValueError) as error: + raise ContractError(f"{label} is not a safe path") from error + +def _status_string(value: Any, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ContractError(f"{label} must be a non-empty string") + return value + def _validate_session_status(root: Path, payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any], Path]: - if set(payload) != {"target", "worktree", "problems"} or payload.get("problems") != []: + if not isinstance(payload, dict) or set(payload) != {"target", "worktree", "problems"} or payload.get("problems") != []: raise ContractError("Kent worktree status is not the exact clean closed shape") target = payload.get("target") worktree = payload.get("worktree") @@ -189,53 +217,90 @@ def _validate_session_status(root: Path, payload: dict[str, Any]) -> tuple[dict[ if not isinstance(worktree, dict) or set(worktree) not in ({"recorded_root", "observed_root", "display_name", "recorded_branch_ref"}, {"recorded_root", "observed_root"}): raise ContractError("Kent worktree status roots are not the stock closed shape") primary = git_common_root(root) - if not isinstance(target["WorkspaceID"], str) or not target["WorkspaceID"].strip() or not all(isinstance(target.get(key), str) and target[key].strip() for key in ("WorkspaceName", "WorkspaceRoot", "EffectiveWorkdir")) or target["WorkspaceAvailability"] != "available" or target["CwdRelpath"] != "." or Path(target["WorkspaceRoot"]).expanduser().resolve() != primary: + workspace_id = _status_string(target.get("WorkspaceID"), "WorkspaceID") + workspace_name = _status_string(target.get("WorkspaceName"), "WorkspaceName") + workspace_root = _resolved_status_path(target.get("WorkspaceRoot"), "WorkspaceRoot") + effective_workdir = _resolved_status_path(target.get("EffectiveWorkdir"), "EffectiveWorkdir") + if not isinstance(target["WorkspaceAvailability"], str) or not isinstance(target["CwdRelpath"], str) or target["WorkspaceAvailability"] != "available" or target["CwdRelpath"] != "." or workspace_root != primary: raise ContractError("Kent worktree status workspace identity drifted") + target["WorkspaceID"], target["WorkspaceName"] = workspace_id, workspace_name + target["WorkspaceRoot"], target["EffectiveWorkdir"] = str(workspace_root), str(effective_workdir) for key in ("recorded_root", "observed_root"): - if not isinstance(worktree[key], str) or not worktree[key].strip(): - raise ContractError("Kent worktree status root is incomplete") + worktree[key] = str(_resolved_status_path(worktree.get(key), f"worktree.{key}")) + task_worktree = target.get("Worktree") + if task_worktree is not None: + if not isinstance(task_worktree, dict) or set(task_worktree) != {"ID", "Name", "Root", "Availability"}: + raise ContractError("Kent active Session Worktree is not the stock closed shape") + task_worktree["ID"] = _status_string(task_worktree.get("ID"), "Worktree.ID") + task_worktree["Name"] = _status_string(task_worktree.get("Name"), "Worktree.Name") + task_worktree["Root"] = str(_resolved_status_path(task_worktree.get("Root"), "Worktree.Root")) + if not isinstance(task_worktree["Availability"], str) or task_worktree["Availability"] != "available": + raise ContractError("Worktree.Availability is not available") if set(worktree) == {"recorded_root", "observed_root", "display_name", "recorded_branch_ref"}: - if worktree["display_name"] != target.get("Worktree", {}).get("Name") or worktree["recorded_branch_ref"] != current_branch_ref(root): + worktree["display_name"] = _status_string(worktree.get("display_name"), "worktree.display_name") + worktree["recorded_branch_ref"] = _status_string(worktree.get("recorded_branch_ref"), "worktree.recorded_branch_ref") + if not re.fullmatch(r"refs/heads/[A-Za-z0-9._/-]+", worktree["recorded_branch_ref"]) or not isinstance(task_worktree, dict) or worktree["display_name"] != task_worktree["Name"] or worktree["recorded_branch_ref"] != current_branch_ref(root): raise ContractError("Kent worktree status display or branch identity drifted") return target, worktree, primary -def session_identity(root: Path, session_id: str, *, moved: bool = False) -> dict[str, str]: - payload = session_worktree_status(root, session_id) +def classify_session_status(root: Path, payload: dict[str, Any]) -> tuple[str, dict[str, str]]: target, roots, primary = _validate_session_status(root, payload) - if moved: - if set(roots) != {"recorded_root", "observed_root"} or target["Worktree"] is not None or Path(target["EffectiveWorkdir"]).expanduser().resolve() != primary or any(Path(roots[key]).expanduser().resolve() != primary for key in ("recorded_root", "observed_root")): - raise ContractError("Kent post-leave Session identity is not exact") - return { - "workspace_id": target["WorkspaceID"], - "workspace_name": target["WorkspaceName"], - "workspace_root": str(primary), - "worktree_id": "", - "worktree_name": "", - "worktree_root": str(primary), - "recorded_branch_ref": "", - } + selected_root = _resolved_status_path(str(root), "selected worktree") + if target["Worktree"] is None: + if set(roots) != {"recorded_root", "observed_root"}: + raise ContractError("Kent post-leave Session roots are not exact") + if target["EffectiveWorkdir"] == str(primary) and all(roots[key] == str(primary) for key in ("recorded_root", "observed_root")): + return "primary", { + "workspace_id": target["WorkspaceID"], + "workspace_name": target["WorkspaceName"], + "workspace_root": str(primary), + "worktree_id": "", + "worktree_name": "", + "worktree_root": str(primary), + "recorded_branch_ref": "", + } + return "foreign", {} task_worktree = target.get("Worktree") - if set(roots) != {"recorded_root", "observed_root", "display_name", "recorded_branch_ref"} or not isinstance(task_worktree, dict) or set(task_worktree) != {"ID", "Name", "Root", "Availability"} or task_worktree["Availability"] != "available": - raise ContractError("Kent active Session Worktree is not exact") - if Path(target["EffectiveWorkdir"]).expanduser().resolve() != root or Path(task_worktree["Root"]).expanduser().resolve() != root or any(Path(roots[key]).expanduser().resolve() != root for key in ("recorded_root", "observed_root")): - raise ContractError("Kent active Session roots do not match the selected worktree") - return { + if set(roots) != {"recorded_root", "observed_root", "display_name", "recorded_branch_ref"}: + raise ContractError("Kent active Session roots are not exact") + identity = { "workspace_id": target["WorkspaceID"], "workspace_name": target["WorkspaceName"], "workspace_root": str(primary), "worktree_id": task_worktree["ID"], "worktree_name": task_worktree["Name"], - "worktree_root": str(root), + "worktree_root": task_worktree["Root"], "recorded_branch_ref": roots["recorded_branch_ref"], } + if target["EffectiveWorkdir"] == str(selected_root) and task_worktree["Root"] == str(selected_root) and all(roots[key] == str(selected_root) for key in ("recorded_root", "observed_root")): + return "active", identity + return "foreign", identity + +def session_identity(root: Path, session_id: str, *, moved: bool = False) -> dict[str, str]: + payload = session_worktree_status(root, session_id) + classification, identity = classify_session_status(root, payload) + expected = "primary" if moved else "active" + if classification != expected: + raise ContractError(f"Kent Session is not the exact {expected} state") + return identity def session_targets_worktree(root: Path, session_id: str) -> bool: - session_identity(root, session_id) - return True + payload = session_worktree_status(root, session_id) + classification, _ = classify_session_status(root, payload) + if classification == "active": + return True + if classification == "primary": + return False + raise ContractError("Kent Session is foreign to the selected worktree") def session_preserves_worktree_identity(root: Path, session_id: str) -> bool: - session_identity(root, session_id, moved=True) - return True + payload = session_worktree_status(root, session_id) + classification, _ = classify_session_status(root, payload) + if classification == "primary": + return True + if classification == "active": + return False + raise ContractError("Kent Session is foreign to the selected Workspace") def task_show(root: Path, task_id: str) -> dict[str, Any]: result = run([_kent_bin(root), "task", "show", task_id, "--project", PROJECT_ID, "--json"], cwd=root, env=child_env(), check=False) @@ -818,7 +883,7 @@ def discover_release_run(root: Path, tag: str, target: str) -> dict[str, Any]: if len(matches) != 1: raise ContractError("release run is missing or ambiguous") row = matches[0] - if not isinstance(row.get("id"), int) or row["id"] <= 0 or not isinstance(row.get("run_attempt"), int) or row["run_attempt"] <= 0: + if isinstance(row.get("id"), bool) or not isinstance(row.get("id"), int) or row["id"] <= 0 or isinstance(row.get("run_attempt"), bool) or not isinstance(row.get("run_attempt"), int) or row["run_attempt"] <= 0: raise ContractError("discovered run identity is invalid") return {"schema": "puber_release_run_v1", "state": "selected", "repository": REPOSITORY, "workflow_path": ".github/workflows/release.yml", "workflow_name": "Release", "event": "push", "run_id": row["id"], "attempt": row["run_attempt"], "head_sha": target, "ref": f"refs/tags/{tag}", "head_branch": tag} @@ -832,7 +897,7 @@ def read_release_run(root: Path, run: Mapping[str, Any], target: str, tag: str) if not isinstance(jobs_response, dict) or not isinstance(jobs_response.get("jobs"), list) or len(jobs_response["jobs"]) != 1: raise ContractError("release jobs are missing, extra, or ambiguous") job = jobs_response["jobs"][0] - if not isinstance(job, dict) or job.get("name") != "Build & Publish" or job.get("status") != "completed" or job.get("conclusion") != "success" or not isinstance(job.get("id"), int) or job["id"] <= 0: + if not isinstance(job, dict) or job.get("name") != "Build & Publish" or job.get("status") != "completed" or job.get("conclusion") != "success" or isinstance(job.get("id"), bool) or not isinstance(job.get("id"), int) or job["id"] <= 0: raise ContractError("release job identity or outcome is invalid") return {"run_id": run["run_id"], "attempt": run["attempt"], "path": response["path"], "name": response["name"], "event": response["event"], "head_sha": response["head_sha"], "head_branch": response["head_branch"], "status": response["status"], "conclusion": response["conclusion"]}, {"id": job["id"], "name": job["name"], "status": job["status"], "conclusion": job["conclusion"], "attempt": run["attempt"]} @@ -851,9 +916,11 @@ def read_release(root: Path, tag: str, target: str) -> dict[str, Any]: raise ContractError("GitHub Release asset names are not exact") normalized_assets = [] for asset in assets: - if not isinstance(asset, dict) or not isinstance(asset.get("id"), int) or asset["id"] <= 0 or not isinstance(asset.get("size"), int) or asset["size"] <= 0 or not isinstance(asset.get("name"), str) or not isinstance(asset.get("browser_download_url"), str) or not asset["browser_download_url"].startswith("https://"): + if not isinstance(asset, dict) or isinstance(asset.get("id"), bool) or not isinstance(asset.get("id"), int) or asset["id"] <= 0 or isinstance(asset.get("size"), bool) or not isinstance(asset.get("size"), int) or asset["size"] <= 0 or not isinstance(asset.get("name"), str) or not isinstance(asset.get("browser_download_url"), str) or not asset["browser_download_url"].startswith("https://"): raise ContractError("GitHub Release asset identity/size is invalid") normalized_assets.append({"id": asset["id"], "name": asset["name"], "size": asset["size"], "browser_download_url": asset["browser_download_url"]}) + if len({asset["id"] for asset in normalized_assets}) != len(normalized_assets): + raise ContractError("GitHub Release asset IDs are duplicated") apk = next(item for item in normalized_assets if item["name"].endswith(".apk")) checksum = next(item for item in normalized_assets if item["name"].endswith(".apk.sha256")) apk_bytes = gh_api_bytes(root, f"repos/{REPOSITORY}/releases/assets/{apk['id']}") diff --git a/.kent/scripts/workflow-release-cleanup b/.kent/scripts/workflow-release-cleanup index 6551766f..115ade13 100755 --- a/.kent/scripts/workflow-release-cleanup +++ b/.kent/scripts/workflow-release-cleanup @@ -3,6 +3,7 @@ from __future__ import annotations import importlib.util import json +import stat import sys from importlib.machinery import SourceFileLoader from pathlib import Path @@ -29,8 +30,12 @@ def _eligible_session(state: dict) -> tuple[str, dict[str, str]]: session_id = helper.require_string(row.get("session_id"), "cleanup_session_id") helper.require_string(row.get("session_name"), "session_name") helper.require_string(row.get("node_display_name"), "node_display_name") - if helper.session_targets_worktree(ROOT, session_id): - candidates.append((session_id, helper.session_identity(ROOT, session_id))) + status = helper.session_worktree_status(ROOT, session_id) + classification, identity = helper.classify_session_status(ROOT, status) + if classification == "active": + candidates.append((session_id, identity)) + elif classification != "primary": + raise helper.ContractError("Kent live Session is foreign to the selected worktree") if len(candidates) != 1: raise helper.ContractError("exactly one stock Kent Session must target this task worktree") return candidates[0] @@ -38,7 +43,10 @@ def _eligible_session(state: dict) -> tuple[str, dict[str, str]]: def _session_for_cleanup(state: dict, prior_session: str | None = None, prior_identity: dict[str, str] | None = None) -> tuple[str, dict[str, str]]: if prior_session is not None: prior_session = helper.require_string(prior_session, "cleanup_session_id") - current = helper.session_identity(ROOT, prior_session, moved=True) + status = helper.session_worktree_status(ROOT, prior_session) + classification, current = helper.classify_session_status(ROOT, status) + if classification != "primary": + raise helper.ContractError("prior cleanup Session is not in the exact post-leave primary state") if not isinstance(prior_identity, dict) or any(current.get(key) != prior_identity.get(key) for key in ("workspace_id", "workspace_name", "workspace_root")): raise helper.ContractError("prior cleanup Session workspace identity drifted") return prior_session, prior_identity @@ -69,8 +77,8 @@ def _parse_janitor_report(raw: str, task_short_id: str) -> tuple[dict, str, str, if marker["task_short_id"] != task_short_id or marker_line != lines[marker_index]: raise helper.ContractError("Janitor terminal evidence marker has the wrong task or is not canonical") annotations = [line for index, line in enumerate(lines[1:], start=1) if index != marker_index] - if not annotations or any(not line.startswith("Janitor ") or not line.strip() for line in annotations): - raise helper.ContractError("Janitor cleanup_report suffix is not a bounded Janitor blocker") + if not annotations or not annotations[0].startswith("Janitor ") or any(not line.strip() or "\x00" in line for line in annotations): + raise helper.ContractError("Janitor cleanup_report annotations are not a bounded Janitor blocker") suffix = "\n".join(annotations) if len(suffix.encode("utf-8")) > 512 * 1024: raise helper.ContractError("Janitor cleanup_report annotations are unbounded") @@ -78,7 +86,7 @@ def _parse_janitor_report(raw: str, task_short_id: str) -> tuple[dict, str, str, raise helper.ContractError("Janitor terminal marker redaction digest does not match the canonical report line") return previous, marker_line, suffix, lines[0] -def _validate_carriers(state: dict, carrier: dict) -> tuple[dict | None, dict | None]: +def _validate_carriers(state: dict, carrier: dict, *, require_release_run: bool = False) -> tuple[dict | None, dict | None]: publication = None if carrier.get("publication_report"): publication = helper.parse_publication_report( @@ -112,18 +120,74 @@ def _validate_carriers(state: dict, carrier: dict) -> tuple[dict | None, dict | raise helper.ContractError("cleanup release/publication report digest mismatch") if release["notes_digest"] != carrier.get("release_notes_digest"): raise helper.ContractError("cleanup release/notes digest mismatch") + if require_release_run: + if release is None: + raise helper.ContractError("published cleanup is missing its release report") + outer_run = helper.parse_run_carrier(carrier.get("release_run")) + if outer_run == helper.NOT_SELECTED_RUN or outer_run != release["run"]: + raise helper.ContractError("cleanup release_run carrier does not equal the nested release report run") return publication, release def _validate_terminal_marker_ledger(task_short_id: str, marker: dict[str, object], kit: dict[str, object]) -> None: runtime_root = ROOT / ".kent" / "runtime" marker_digest = kit["canonical_sha256"](marker) - active = runtime_root / task_short_id / "evidence-ledger.jsonl" - tombstone = runtime_root / f".evidence-cleanup-{marker_digest}" / "evidence-ledger.jsonl" - candidates = [path for path in (active, tombstone) if path.exists()] - if len(candidates) != 1 or any(path.is_symlink() or not path.is_file() for path in candidates): - raise helper.ContractError("terminal evidence task ledger/tombstone is missing or ambiguous") + kent_root = ROOT / ".kent" + for path, label in ((kent_root, ".kent"), (runtime_root, ".kent/runtime")): + try: + metadata = path.lstat() + except (OSError, ValueError) as error: + raise helper.ContractError(f"terminal evidence {label} is missing") from error + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode): + raise helper.ContractError(f"terminal evidence {label} is unsafe") + expected_name = f".evidence-cleanup-{marker_digest}" + tombstone_names = [] + try: + runtime_entries = list(runtime_root.iterdir()) + except (OSError, ValueError) as error: + raise helper.ContractError("terminal evidence runtime directory is unreadable") from error + for entry in runtime_entries: + if entry.name.startswith(".evidence-cleanup-"): + if entry.name != expected_name: + raise helper.ContractError("terminal evidence has an unexpected cleanup tombstone") + tombstone_names.append(entry) + if len(tombstone_names) > 1: + raise helper.ContractError("terminal evidence has duplicate cleanup tombstones") + active_dir = runtime_root / task_short_id + tombstone_dir = runtime_root / expected_name + def component(path: Path, label: str, directory: bool, *, missing_ok: bool = False) -> bool: + try: + metadata = path.lstat() + except FileNotFoundError: + if missing_ok: + return False + raise helper.ContractError(f"terminal evidence {label} is missing") + except (OSError, ValueError) as error: + raise helper.ContractError(f"terminal evidence {label} is unreadable") from error + if stat.S_ISLNK(metadata.st_mode) or (stat.S_ISDIR(metadata.st_mode) != directory): + raise helper.ContractError(f"terminal evidence {label} is unsafe") + return True + active_present = component(active_dir, "active task directory", True, missing_ok=True) + tombstone_present = component(tombstone_dir, "cleanup tombstone directory", True, missing_ok=True) + if bool(tombstone_names) != tombstone_present: + raise helper.ContractError("terminal evidence cleanup tombstone component is ambiguous") + if active_present and tombstone_present: + raise helper.ContractError("terminal evidence has both active ledger and cleanup tombstone") + if not active_present and not tombstone_present: + raise helper.ContractError("terminal evidence task ledger/tombstone is missing") + selected_dir = active_dir if active_present else tombstone_dir + ledger = selected_dir / "evidence-ledger.jsonl" + component(ledger, "evidence ledger", False) + try: + entries = list(selected_dir.iterdir()) + except (OSError, ValueError) as error: + raise helper.ContractError("terminal evidence ledger directory is unreadable") from error + if any(entry.name != "evidence-ledger.jsonl" for entry in entries): + raise helper.ContractError("terminal evidence ledger directory has unexpected entries") try: - records = [json.loads(line) for line in candidates[0].read_text(encoding="utf-8").splitlines() if line.strip()] + raw_lines = ledger.read_text(encoding="utf-8").splitlines() + if not raw_lines or any(not line.strip() or "\x00" in line for line in raw_lines): + raise helper.ContractError("terminal evidence ledger contains empty or NUL data") + records = [json.loads(line) for line in raw_lines] readback = kit["validate_terminal_chain"](records, task_short_id=task_short_id) except Exception as error: raise helper.ContractError(f"terminal evidence ledger readback is invalid: {error}") from error @@ -259,7 +323,7 @@ def main() -> int: operation_digest = fresh["binding"].provenance_fingerprint session, session_identity = _session_for_cleanup(state) carrier = payload - _validate_carriers(state, carrier) + _validate_carriers(state, carrier, require_release_run=edge["key"] == "release_release_published") declarations = helper.declarations(state["task"].get("body")) version = declarations.get("release_version") branch = carrier.get("branch_name") or declarations.get("release_branch", "") diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json index 202cbd73..2b2020e4 100644 --- a/.kent/workflows/puber-release.manifest.json +++ b/.kent/workflows/puber-release.manifest.json @@ -71,7 +71,7 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-release-cleanup=94edc3d2f1381198f6f4c855f6874cdf62deb05e3e969de2dc136a371187d7bc", + "key": ".kent/scripts/tests/test-workflow-release-cleanup=618c525fcf41f1ccb5bcf56f121990effbefcdf81bb4652c357b6f85ed8282cb", "runtime_digest_required": true }, { @@ -81,17 +81,17 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-wait-github-release=cbe45d2cae8b864f98db13aac71ad63d7c35b1afe7c014b799c57da3d5903d49", + "key": ".kent/scripts/tests/test-workflow-wait-github-release=4744760985167e0f454fb77975283814c38b06a90592a323d12e293fcd13eab1", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-puber-release-intent=6fe9c4a3086ea73bd3b4cb8eee2d3c493d67f1a4bf42a76647b0571783c27f30", + "key": ".kent/scripts/workflow-puber-release-intent=bba37cb12c2d0640f773c1a4d6f5fcaf71e5ea167c1e392c9522621064b658eb", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-release-cleanup=00809921011264166af686b5cefddab84dee12d783531fa50a5bc837a6be287e", + "key": ".kent/scripts/workflow-release-cleanup=3705599e2e25e1e88a9bd012b2b880b25c34b228936ceb1c1683730b9fb89272", "runtime_digest_required": true }, { From 13faad61e1862dc9a7756090aca40bf3e790acc7 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 09:04:19 +0500 Subject: [PATCH 16/21] Close final release carrier and retention checks --- .../tests/test-workflow-release-cleanup | 83 ++++++++++++++++++- .../tests/test-workflow-wait-github-release | 16 ++-- .kent/scripts/workflow-puber-release-intent | 14 ++-- .kent/scripts/workflow-release-cleanup | 17 +++- .kent/workflows/puber-release.manifest.json | 8 +- 5 files changed, 116 insertions(+), 22 deletions(-) diff --git a/.kent/scripts/tests/test-workflow-release-cleanup b/.kent/scripts/tests/test-workflow-release-cleanup index ca7c4750..692820e5 100755 --- a/.kent/scripts/tests/test-workflow-release-cleanup +++ b/.kent/scripts/tests/test-workflow-release-cleanup @@ -382,6 +382,12 @@ with tempfile.TemporaryDirectory() as directory: ("wrong branch", lambda value: value["worktree"].__setitem__("recorded_branch_ref", "refs/heads/foreign")), ("wrong display", lambda value: value["worktree"].__setitem__("display_name", "foreign")), ("foreign Workspace", lambda value: value["target"].__setitem__("WorkspaceRoot", str(root / "foreign-primary"))), + ("WorkspaceID whitespace", lambda value: value["target"].__setitem__("WorkspaceID", " workspace-fixture")), + ("WorkspaceName whitespace", lambda value: value["target"].__setitem__("WorkspaceName", "Puber ")), + ("WorktreeID whitespace", lambda value: value["target"]["Worktree"].__setitem__("ID", " worktree-fixture")), + ("WorktreeName whitespace", lambda value: value["target"]["Worktree"].__setitem__("Name", "PUB-99 ")), + ("display whitespace", lambda value: value["worktree"].__setitem__("display_name", "PUB-99 ")), + ("branch whitespace", lambda value: value["worktree"].__setitem__("recorded_branch_ref", "refs/heads/release/99.0.0 ")), ): candidate = json.loads(json.dumps(active_status)) mutation(candidate) @@ -447,6 +453,16 @@ with tempfile.TemporaryDirectory() as directory: cleanup_mod._validate_terminal_marker_ledger("PUB-99", marker, ledger_kit) runtime_root = managed / ".kent/runtime" expected_tombstone = runtime_root / f".evidence-cleanup-{ledger_kit['canonical_sha256'](marker)}" + for checkpoint in ("fix-checkpoint.json", "smoke-checkpoint.json"): + checkpoint_path = runtime_root / "PUB-99" / checkpoint + checkpoint_path.write_text("{}", encoding="utf-8") + checkpoint_path.chmod(0o600) + cleanup_mod._validate_terminal_marker_ledger("PUB-99", marker, ledger_kit) + for checkpoint in ("fix-checkpoint.json", "smoke-checkpoint.json"): + (runtime_root / "PUB-99" / checkpoint).unlink() + unexpected = runtime_root / "PUB-99" / "unexpected.txt" + unexpected.write_text("unexpected", encoding="utf-8") + unexpected.chmod(0o600) def expect_ledger_rejected(label: str) -> None: try: @@ -454,6 +470,8 @@ with tempfile.TemporaryDirectory() as directory: except cleanup_mod.helper.ContractError: return raise AssertionError(label) + expect_ledger_rejected("unexpected active task entry") + unexpected.unlink() wrong_tombstone = runtime_root / (".evidence-cleanup-" + "0" * 64) wrong_tombstone.mkdir() @@ -509,6 +527,32 @@ with tempfile.TemporaryDirectory() as directory: release_digest = hashlib.sha256(release_report.encode()).hexdigest() valid_carrier = {"publication_report": publication, "publication_report_digest": publication_digest, "release_report": release_report, "release_report_digest": release_digest, "release_notes_path": carrier_notes["path"], "release_notes_digest": carrier_notes["sha256"]} assert cleanup_mod._validate_carriers({"task_short_id": "PUB-99"}, valid_carrier)[0] is not None + def reject_parser(label: str, action) -> None: + try: + action() + except cleanup_mod.helper.ContractError: + return + raise AssertionError(label) + + for label, mutation in ( + ("boolean run id", lambda value: value["run"].__setitem__("run_id", True)), + ("boolean run attempt", lambda value: value["run"].__setitem__("attempt", True)), + ("boolean API run id", lambda value: value["jobs"]["run"].__setitem__("run_id", True)), + ("boolean API run attempt", lambda value: value["jobs"]["run"].__setitem__("attempt", True)), + ("boolean job id", lambda value: value["jobs"]["job"].__setitem__("id", True)), + ("boolean job attempt", lambda value: value["jobs"]["job"].__setitem__("attempt", True)), + ("boolean asset id", lambda value: value["assets"][0].__setitem__("id", True)), + ("boolean asset size", lambda value: value["assets"][0].__setitem__("size", True)), + ("duplicate asset ids", lambda value: value["assets"][1].__setitem__("id", value["assets"][0]["id"])), + ): + candidate = json.loads(release_report) + mutation(candidate) + encoded = cleanup_mod.helper.canonical_json(candidate) + reject_parser(label, lambda encoded=encoded: cleanup_mod.helper.parse_release_report(encoded, hashlib.sha256(encoded.encode()).hexdigest(), expected_publication_digest=publication_digest, expected_notes_digest=carrier_notes["sha256"], expected_tag="v99.0.0", expected_target=target)) + malformed_publication = json.loads(publication) + malformed_publication["operation_digest"] = "A" * 64 + malformed_publication_encoded = cleanup_mod.helper.canonical_json(malformed_publication) + reject_parser("malformed current publication operation digest", lambda: cleanup_mod.helper.parse_publication_report(malformed_publication_encoded, hashlib.sha256(malformed_publication_encoded.encode()).hexdigest(), task_short_id="PUB-99", root=managed)) broken_carrier = dict(valid_carrier) broken_value = json.loads(release_report) broken_value["run"]["head_sha"] = "e" * 40 @@ -561,6 +605,13 @@ with tempfile.TemporaryDirectory() as directory: assert managed.exists() and (primary / ".git").exists() assert not active_dir.exists() and expected_tombstone.is_dir() cleanup_mod._validate_terminal_marker_ledger("PUB-99", marker, ledger_kit) + for checkpoint in ("fix-checkpoint.json", "smoke-checkpoint.json"): + checkpoint_path = expected_tombstone / checkpoint + checkpoint_path.write_text("{}", encoding="utf-8") + checkpoint_path.chmod(0o600) + cleanup_mod._validate_terminal_marker_ledger("PUB-99", marker, ledger_kit) + for checkpoint in ("fix-checkpoint.json", "smoke-checkpoint.json"): + (expected_tombstone / checkpoint).unlink() tombstone_backup = expected_tombstone.with_name(expected_tombstone.name + ".backup") expected_tombstone.rename(tombstone_backup) expected_tombstone.symlink_to(tombstone_backup.name, target_is_directory=True) @@ -736,12 +787,38 @@ with tempfile.TemporaryDirectory() as directory: assert release_janitor.returncode == 0, release_janitor.stderr assert json.loads(release_janitor.stdout)["transition"] == "task_janitor_blocked" - for index, mutation in enumerate(("head_sha", "head_branch", "ref"), start=102): + boundary_mutations = ("head_sha", "head_branch", "ref", "run_id", "run_attempt", "api_run_id", "api_run_attempt", "job_id", "job_attempt", "asset_id", "asset_size", "duplicate_asset_ids", "publication_operation_digest") + for index, mutation in enumerate(boundary_mutations, start=102): with tempfile.TemporaryDirectory(dir=root) as edge_directory: edge = make_edge_fixture(Path(edge_directory), f"PUB-{index}", f"{index}.0.0", cleanup_node) carrier = release_carrier_for(edge) value = json.loads(carrier["release_report"]) - value["run"][mutation] = "f" * 40 if mutation == "head_sha" else f"v{index}.9.9" if mutation == "head_branch" else f"refs/tags/v{index}.9.9" + if mutation in {"head_sha", "head_branch", "ref"}: + value["run"][mutation] = "f" * 40 if mutation == "head_sha" else f"v{index}.9.9" if mutation == "head_branch" else f"refs/tags/v{index}.9.9" + elif mutation == "run_id": + value["run"]["run_id"] = True + elif mutation == "run_attempt": + value["run"]["attempt"] = True + elif mutation == "api_run_id": + value["jobs"]["run"]["run_id"] = True + elif mutation == "api_run_attempt": + value["jobs"]["run"]["attempt"] = True + elif mutation == "job_id": + value["jobs"]["job"]["id"] = True + elif mutation == "job_attempt": + value["jobs"]["job"]["attempt"] = True + elif mutation == "asset_id": + value["assets"][0]["id"] = True + elif mutation == "asset_size": + value["assets"][0]["size"] = True + elif mutation == "duplicate_asset_ids": + value["assets"][1]["id"] = value["assets"][0]["id"] + elif mutation == "publication_operation_digest": + publication_value = json.loads(carrier["publication_report"]) + publication_value["operation_digest"] = "A" * 64 + carrier["publication_report"] = cleanup_mod.helper.canonical_json(publication_value) + carrier["publication_report_digest"] = hashlib.sha256(carrier["publication_report"].encode()).hexdigest() + value["publication_report_digest"] = carrier["publication_report_digest"] invalid_report = cleanup_mod.helper.canonical_json(value) invalid = {**carrier, "release_report": invalid_report, "release_report_digest": hashlib.sha256(invalid_report.encode()).hexdigest(), "release_run": cleanup_mod.helper.canonical_json(value["run"])} invalid_payload = {"_kent": {"task_id": edge["task"]["summary"]["id"], "node_id": cleanup_node["id"], "transition_branch_key": "release_release_published"}, **{key: value for key, value in invalid.items() if not key.startswith("_")}} @@ -765,4 +842,4 @@ with tempfile.TemporaryDirectory() as directory: expect_rejected([str(managed / ".kent/scripts/workflow-release-cleanup")], managed, {**retry, "cleanup_report": initial_lines[0] + "\n" + marker_hash_line + "\nJanitor tampered"}, env, "tampered marker hash") expect_rejected([str(managed / ".kent/scripts/workflow-release-cleanup")], managed, {**retry, "cleanup_report": initial_report + "\nTERMINAL_EVIDENCE_V1 extra"}, env, "extra terminal marker") -print("workflow-release-cleanup: passed scenarios=one_read_session_selection,malformed_status_matrix,dirty_primary,ledger_active_tombstone_safety,cancel_edge,close_without_merge_edge,release_carrier_edge,outer_release_run_binding,selected_run_head_sha_branch_ref_boundary,janitor_active_block,multiline_repeated_retries,post_leave_block,third_retry_block,marker_adversarial,unrelated_ledger") +print("workflow-release-cleanup: passed scenarios=one_read_session_selection,malformed_status_matrix,whitespace_status_rejection,dirty_primary,ledger_checkpoint_matrix,ledger_active_tombstone_safety,cancel_edge,close_without_merge_edge,release_carrier_edge,outer_release_run_binding,deep_parser_boundary_matrix,selected_run_head_sha_branch_ref_boundary,janitor_active_block,multiline_repeated_retries,post_leave_block,third_retry_block,marker_adversarial,unrelated_ledger") diff --git a/.kent/scripts/tests/test-workflow-wait-github-release b/.kent/scripts/tests/test-workflow-wait-github-release index 1a98503e..b7da1623 100755 --- a/.kent/scripts/tests/test-workflow-wait-github-release +++ b/.kent/scripts/tests/test-workflow-wait-github-release @@ -41,7 +41,7 @@ with tempfile.TemporaryDirectory() as directory: gh = fake / "gh" gh.write_text( """#!/usr/bin/env python3 -import json,os,subprocess,sys +import hashlib,json,os,subprocess,sys args=sys.argv[1:]; target=os.environ["PUBER_TARGET"]; tag="v99.0.0"; mode=os.environ.get("PUBER_MODE","normal") with open(os.environ["PUBER_CALLS"], "a", encoding="utf-8") as calls: calls.write(json.dumps(args)+"\\n") if args[:2] == ["release","edit"]: @@ -82,10 +82,11 @@ if "/actions/runs/42/attempts/1" in endpoint: run_conclusion="failure" if mode == "failed_run" else "success" print(json.dumps({"id":run_id,"run_attempt":run_attempt,"path":".github/workflows/release.yml","name":"Release","event":"push","head_sha":run_sha,"head_branch":run_branch,"repository":{"full_name":"rovkinmax/Puber"},"status":"completed","conclusion":run_conclusion})); sys.exit(0) if "/releases/assets/1" in endpoint: - if mode == "invalid_utf8": sys.stdout.buffer.write(bytes((255,254))); sys.exit(0) sys.stdout.buffer.write(open(os.environ["PUBER_APK"],"rb").read()); sys.exit(0) if "/releases/assets/2" in endpoint: if mode == "invalid_utf8": sys.stdout.buffer.write(bytes((255,254))); sys.exit(0) + if mode == "checksum_filename_mismatch": + sys.stdout.write(hashlib.sha256(open(os.environ["PUBER_APK"],"rb").read()).hexdigest() + " wrong.apk\\n"); sys.exit(0) if mode == "checksum_digest_mismatch": sys.stdout.write("0"*64 + " puber-v99.0.0.apk\\n"); sys.exit(0) sys.stdout.buffer.write(open(os.environ["PUBER_CHECKSUM"],"rb").read()); sys.exit(0) @@ -98,7 +99,8 @@ if "/releases/tags/" in endpoint: if mode == "zero_asset_size": assets[0]["size"]=0 if mode == "invalid_asset_size": assets[0]["size"]="invalid" if mode == "download_size_mismatch": assets[0]["size"] += 1 - if mode == "checksum_filename_mismatch": assets[1]["name"]="wrong.sha256" + if mode == "invalid_utf8": assets[1]["size"]=2 + if mode == "checksum_filename_mismatch": assets[1]["size"]=len(hashlib.sha256(apk).hexdigest() + " wrong.apk\\n") print(json.dumps({"tag_name":tag,"target_commitish":"master","name":"Puber "+tag,"draft":False,"prerelease":False,"html_url":"https://github.com/rovkinmax/Puber/releases/tag/"+tag,"body":open(os.environ["PUBER_BODY"],encoding="utf-8").read(),"assets":assets})); sys.exit(0) sys.exit(3) """, @@ -132,10 +134,12 @@ sys.exit(3) assert release_report["checksum"]["apk_checksum_equal"] is True assert body_file.read_text(encoding="utf-8").startswith("puber_release_notes_v1") - def rejected(label: str, action) -> None: + def rejected(label: str, action, expected_message: str | None = None) -> None: try: action() - except publish_fixture.helper.ContractError: + except publish_fixture.helper.ContractError as error: + if expected_message and expected_message not in str(error): + raise AssertionError(f"{label}: unexpected ContractError: {error}") return raise AssertionError(label) @@ -162,7 +166,7 @@ sys.exit(3) for mode in ("missing_assets", "extra_assets", "duplicate_asset_id", "zero_asset_size", "invalid_asset_size", "download_size_mismatch", "checksum_filename_mismatch", "checksum_digest_mismatch", "invalid_utf8"): os.environ["PUBER_MODE"] = mode env["PUBER_MODE"] = mode - rejected(f"API release {mode}", lambda mode=mode: publish_fixture.helper.read_release(work, "v99.0.0", target)) + rejected(f"API release {mode}", lambda mode=mode: publish_fixture.helper.read_release(work, "v99.0.0", target), {"invalid_utf8": "checksum asset is not valid UTF-8", "checksum_filename_mismatch": "checksum asset does not match APK bytes"}.get(mode)) os.environ["PUBER_MODE"] = "normal" env["PUBER_MODE"] = "normal" diff --git a/.kent/scripts/workflow-puber-release-intent b/.kent/scripts/workflow-puber-release-intent index 18ffe7a6..5af0f081 100755 --- a/.kent/scripts/workflow-puber-release-intent +++ b/.kent/scripts/workflow-puber-release-intent @@ -203,7 +203,7 @@ def _resolved_status_path(value: Any, label: str) -> Path: raise ContractError(f"{label} is not a safe path") from error def _status_string(value: Any, label: str) -> str: - if not isinstance(value, str) or not value.strip(): + if not isinstance(value, str) or not value.strip() or value != value.strip(): raise ContractError(f"{label} must be a non-empty string") return value @@ -647,7 +647,7 @@ def parse_run_carrier(raw: Any) -> dict[str, Any]: raise ContractError("release_run carrier is not closed") if value.get("repository") != REPOSITORY or value.get("workflow_path") != ".github/workflows/release.yml" or value.get("workflow_name") != "Release" or value.get("event") != "push": raise ContractError("release_run carrier workflow identity drifted") - if not isinstance(value.get("run_id"), int) or value["run_id"] <= 0 or not isinstance(value.get("attempt"), int) or value["attempt"] <= 0: + if isinstance(value.get("run_id"), bool) or not isinstance(value.get("run_id"), int) or value["run_id"] <= 0 or isinstance(value.get("attempt"), bool) or not isinstance(value.get("attempt"), int) or value["attempt"] <= 0: raise ContractError("release_run carrier IDs are invalid") if not SHA1.fullmatch(str(value.get("head_sha", ""))) or not TAG.fullmatch(str(value.get("head_branch", ""))) or value.get("ref") != f"refs/tags/{value['head_branch']}": raise ContractError("release_run carrier ref/head is invalid") @@ -695,7 +695,7 @@ def parse_publication_report(raw: Any, digest: Any, *, expected_pr: str | None = value = raw if isinstance(raw, dict) else parse_closed_json(raw, "puber_publication_report_v1", PUBLICATION_KEYS) if set(value) != PUBLICATION_KEYS or value.get("schema") != "puber_publication_report_v1": raise ContractError("publication report is not closed") - if not isinstance(digest, str) or digest != sha256_bytes(canonical_json(value).encode("utf-8")) or not HEX64.fullmatch(digest): + if not isinstance(digest, str) or digest != sha256_bytes(canonical_json(value).encode("utf-8")) or not HEX64.fullmatch(digest) or not HEX64.fullmatch(str(value.get("operation_digest", ""))): raise ContractError("publication report digest mismatch") safe_version_tag(value.get("release_version"), value.get("release_tag")) if expected_version and value["release_version"] != expected_version or expected_tag and value["release_tag"] != expected_tag: @@ -729,14 +729,14 @@ def parse_publication_report(raw: Any, digest: Any, *, expected_pr: str | None = def parse_run_api(value: Any) -> dict[str, Any]: if not isinstance(value, dict) or set(value) != RUN_API_KEYS: raise ContractError("release run API evidence is not closed") - if not isinstance(value["run_id"], int) or value["run_id"] <= 0 or not isinstance(value["attempt"], int) or value["attempt"] <= 0: + if isinstance(value["run_id"], bool) or not isinstance(value["run_id"], int) or value["run_id"] <= 0 or isinstance(value["attempt"], bool) or not isinstance(value["attempt"], int) or value["attempt"] <= 0: raise ContractError("release run API IDs are invalid") if value["path"] != ".github/workflows/release.yml" or value["name"] != "Release" or value["event"] != "push" or not SHA1.fullmatch(str(value["head_sha"])) or not TAG.fullmatch(str(value["head_branch"])) or value["status"] != "completed" or value["conclusion"] != "success": raise ContractError("release run API identity/outcome is invalid") return dict(value) def parse_job(value: Any, attempt: int) -> dict[str, Any]: - if not isinstance(value, dict) or set(value) != JOB_KEYS or value.get("name") != "Build & Publish" or value.get("status") != "completed" or value.get("conclusion") != "success" or not isinstance(value.get("id"), int) or value["id"] <= 0 or value.get("attempt") != attempt: + if not isinstance(value, dict) or set(value) != JOB_KEYS or value.get("name") != "Build & Publish" or value.get("status") != "completed" or value.get("conclusion") != "success" or isinstance(value.get("id"), bool) or not isinstance(value.get("id"), int) or value["id"] <= 0 or isinstance(value.get("attempt"), bool) or value.get("attempt") != attempt: raise ContractError("release job evidence is not exact") return dict(value) @@ -794,8 +794,10 @@ def parse_release_report(raw: Any, digest: Any, *, expected_publication_digest: if {asset["name"] for asset in assets} != {f"puber-{metadata['tag_name']}.apk", f"puber-{metadata['tag_name']}.apk.sha256"}: raise ContractError("release report asset names are not exact") for asset in assets: - if not isinstance(asset["id"], int) or asset["id"] <= 0 or not isinstance(asset["size"], int) or asset["size"] <= 0 or not HEX64.fullmatch(str(asset["download_sha256"])) or not isinstance(asset["browser_download_url"], str) or not asset["browser_download_url"].startswith("https://"): + if isinstance(asset["id"], bool) or not isinstance(asset["id"], int) or asset["id"] <= 0 or isinstance(asset["size"], bool) or not isinstance(asset["size"], int) or asset["size"] <= 0 or not HEX64.fullmatch(str(asset["download_sha256"])) or not isinstance(asset["browser_download_url"], str) or not asset["browser_download_url"].startswith("https://"): raise ContractError("release report asset evidence is invalid") + if len({asset["id"] for asset in assets}) != len(assets): + raise ContractError("release report asset IDs are duplicated") checksum = parse_checksum_proof(value["checksum"], assets) if expected_target and metadata["remote_tag_target"] != expected_target or metadata.get("tag_name") != (expected_tag or metadata.get("tag_name")) or metadata.get("body") is None: raise ContractError("release report metadata proof is incomplete") diff --git a/.kent/scripts/workflow-release-cleanup b/.kent/scripts/workflow-release-cleanup index 115ade13..ecf98251 100755 --- a/.kent/scripts/workflow-release-cleanup +++ b/.kent/scripts/workflow-release-cleanup @@ -3,6 +3,7 @@ from __future__ import annotations import importlib.util import json +import os import stat import sys from importlib.machinery import SourceFileLoader @@ -175,14 +176,24 @@ def _validate_terminal_marker_ledger(task_short_id: str, marker: dict[str, objec if not active_present and not tombstone_present: raise helper.ContractError("terminal evidence task ledger/tombstone is missing") selected_dir = active_dir if active_present else tombstone_dir - ledger = selected_dir / "evidence-ledger.jsonl" - component(ledger, "evidence ledger", False) + allowed_entries = {"evidence-ledger.jsonl", "fix-checkpoint.json", "smoke-checkpoint.json"} + def safe_file(path: Path, label: str) -> None: + try: + metadata = path.lstat() + except (OSError, ValueError) as error: + raise helper.ContractError(f"terminal evidence {label} is unreadable") from error + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o600 or metadata.st_size > 8 * 1024 * 1024: + raise helper.ContractError(f"terminal evidence {label} is unsafe") try: entries = list(selected_dir.iterdir()) except (OSError, ValueError) as error: raise helper.ContractError("terminal evidence ledger directory is unreadable") from error - if any(entry.name != "evidence-ledger.jsonl" for entry in entries): + if any(entry.name not in allowed_entries for entry in entries): raise helper.ContractError("terminal evidence ledger directory has unexpected entries") + for entry in entries: + safe_file(entry, entry.name) + ledger = selected_dir / "evidence-ledger.jsonl" + component(ledger, "evidence ledger", False) try: raw_lines = ledger.read_text(encoding="utf-8").splitlines() if not raw_lines or any(not line.strip() or "\x00" in line for line in raw_lines): diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json index 2b2020e4..8ab3a03e 100644 --- a/.kent/workflows/puber-release.manifest.json +++ b/.kent/workflows/puber-release.manifest.json @@ -71,7 +71,7 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-release-cleanup=618c525fcf41f1ccb5bcf56f121990effbefcdf81bb4652c357b6f85ed8282cb", + "key": ".kent/scripts/tests/test-workflow-release-cleanup=5bc70a16145e33a56506e1667b10829667f01cf9d2542f22221e5155be98f1cd", "runtime_digest_required": true }, { @@ -81,17 +81,17 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-wait-github-release=4744760985167e0f454fb77975283814c38b06a90592a323d12e293fcd13eab1", + "key": ".kent/scripts/tests/test-workflow-wait-github-release=b419183f87da69e2dac3db46605db9de8bf7b71d2fa0f5183533de8eaac885e1", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-puber-release-intent=bba37cb12c2d0640f773c1a4d6f5fcaf71e5ea167c1e392c9522621064b658eb", + "key": ".kent/scripts/workflow-puber-release-intent=9734779dcea67d651722d4be2c814c9a935b710533ca3f5809b37704787257ba", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-release-cleanup=3705599e2e25e1e88a9bd012b2b880b25c34b228936ceb1c1683730b9fb89272", + "key": ".kent/scripts/workflow-release-cleanup=7385543e8e27a143a110af13e17b28c137fb5f5c4a57bdcf3ecbe3f9988a1570", "runtime_digest_required": true }, { From c91041b31c2d61cfc12f0da425280a58a1337d9d Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 09:25:38 +0500 Subject: [PATCH 17/21] Finalize release cleanup bytecode and mode checks --- .../tests/test-workflow-puber-release-intent | 2 ++ .../tests/test-workflow-puber-release-publish | 2 ++ .../tests/test-workflow-release-cleanup | 27 ++++++++++++++----- .../tests/test-workflow-wait-github-release | 2 ++ .kent/scripts/workflow-release-cleanup | 2 +- .kent/workflows/puber-release.manifest.json | 10 +++---- 6 files changed, 32 insertions(+), 13 deletions(-) diff --git a/.kent/scripts/tests/test-workflow-puber-release-intent b/.kent/scripts/tests/test-workflow-puber-release-intent index 37884a2e..cc787a2a 100755 --- a/.kent/scripts/tests/test-workflow-puber-release-intent +++ b/.kent/scripts/tests/test-workflow-puber-release-intent @@ -4,6 +4,8 @@ from __future__ import annotations import importlib.util import json import os +import sys +sys.dont_write_bytecode = True from importlib.machinery import SourceFileLoader from pathlib import Path import subprocess diff --git a/.kent/scripts/tests/test-workflow-puber-release-publish b/.kent/scripts/tests/test-workflow-puber-release-publish index ee18a5ec..461c61b9 100755 --- a/.kent/scripts/tests/test-workflow-puber-release-publish +++ b/.kent/scripts/tests/test-workflow-puber-release-publish @@ -4,6 +4,8 @@ from __future__ import annotations import json import importlib.util import os +import sys +sys.dont_write_bytecode = True from importlib.machinery import SourceFileLoader from pathlib import Path import shutil diff --git a/.kent/scripts/tests/test-workflow-release-cleanup b/.kent/scripts/tests/test-workflow-release-cleanup index 692820e5..2a79cc37 100755 --- a/.kent/scripts/tests/test-workflow-release-cleanup +++ b/.kent/scripts/tests/test-workflow-release-cleanup @@ -5,6 +5,8 @@ import hashlib import importlib.util import json import os +import sys +sys.dont_write_bytecode = True from pathlib import Path import shutil import subprocess @@ -106,7 +108,8 @@ def make_edge_fixture(base: Path, short_id: str, version: str, cleanup_node: dic sh(["git", "config", "user.email", "test@example.invalid"], worktree) sh(["git", "config", "user.name", "Puber Test"], worktree) sh(["git", "add", "."], worktree) - sh(["git", "commit", "--quiet", "-m", "cleanup edge fixture"], worktree) + if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=worktree).returncode != 0: + sh(["git", "commit", "--quiet", "-m", "cleanup edge fixture"], worktree) target = sh(["git", "rev-parse", "HEAD"], worktree).stdout.strip() sh(["git", "push", "--quiet", "-u", "origin", branch], worktree) @@ -453,23 +456,31 @@ with tempfile.TemporaryDirectory() as directory: cleanup_mod._validate_terminal_marker_ledger("PUB-99", marker, ledger_kit) runtime_root = managed / ".kent/runtime" expected_tombstone = runtime_root / f".evidence-cleanup-{ledger_kit['canonical_sha256'](marker)}" + def expect_ledger_rejected(label: str) -> None: + try: + cleanup_mod._validate_terminal_marker_ledger("PUB-99", marker, ledger_kit) + except cleanup_mod.helper.ContractError: + return + raise AssertionError(label) + for checkpoint in ("fix-checkpoint.json", "smoke-checkpoint.json"): checkpoint_path = runtime_root / "PUB-99" / checkpoint checkpoint_path.write_text("{}", encoding="utf-8") checkpoint_path.chmod(0o600) cleanup_mod._validate_terminal_marker_ledger("PUB-99", marker, ledger_kit) + read_only_checkpoint = runtime_root / "PUB-99" / "fix-checkpoint.json" + read_only_checkpoint.chmod(0o400) + cleanup_mod._validate_terminal_marker_ledger("PUB-99", marker, ledger_kit) + unsafe_checkpoint = runtime_root / "PUB-99" / "smoke-checkpoint.json" + unsafe_checkpoint.chmod(0o640) + expect_ledger_rejected("group-accessible checkpoint") + unsafe_checkpoint.chmod(0o600) for checkpoint in ("fix-checkpoint.json", "smoke-checkpoint.json"): (runtime_root / "PUB-99" / checkpoint).unlink() unexpected = runtime_root / "PUB-99" / "unexpected.txt" unexpected.write_text("unexpected", encoding="utf-8") unexpected.chmod(0o600) - def expect_ledger_rejected(label: str) -> None: - try: - cleanup_mod._validate_terminal_marker_ledger("PUB-99", marker, ledger_kit) - except cleanup_mod.helper.ContractError: - return - raise AssertionError(label) expect_ledger_rejected("unexpected active task entry") unexpected.unlink() @@ -610,6 +621,8 @@ with tempfile.TemporaryDirectory() as directory: checkpoint_path.write_text("{}", encoding="utf-8") checkpoint_path.chmod(0o600) cleanup_mod._validate_terminal_marker_ledger("PUB-99", marker, ledger_kit) + (expected_tombstone / "fix-checkpoint.json").chmod(0o400) + cleanup_mod._validate_terminal_marker_ledger("PUB-99", marker, ledger_kit) for checkpoint in ("fix-checkpoint.json", "smoke-checkpoint.json"): (expected_tombstone / checkpoint).unlink() tombstone_backup = expected_tombstone.with_name(expected_tombstone.name + ".backup") diff --git a/.kent/scripts/tests/test-workflow-wait-github-release b/.kent/scripts/tests/test-workflow-wait-github-release index b7da1623..5dcd3250 100755 --- a/.kent/scripts/tests/test-workflow-wait-github-release +++ b/.kent/scripts/tests/test-workflow-wait-github-release @@ -4,6 +4,8 @@ from __future__ import annotations import hashlib import importlib.util import json +import sys +sys.dont_write_bytecode = True from importlib.machinery import SourceFileLoader from pathlib import Path import os diff --git a/.kent/scripts/workflow-release-cleanup b/.kent/scripts/workflow-release-cleanup index ecf98251..1a98e5db 100755 --- a/.kent/scripts/workflow-release-cleanup +++ b/.kent/scripts/workflow-release-cleanup @@ -182,7 +182,7 @@ def _validate_terminal_marker_ledger(task_short_id: str, marker: dict[str, objec metadata = path.lstat() except (OSError, ValueError) as error: raise helper.ContractError(f"terminal evidence {label} is unreadable") from error - if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o600 or metadata.st_size > 8 * 1024 * 1024: + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or metadata.st_uid != os.getuid() or (stat.S_IMODE(metadata.st_mode) & 0o077) != 0 or metadata.st_size > 8 * 1024 * 1024: raise helper.ContractError(f"terminal evidence {label} is unsafe") try: entries = list(selected_dir.iterdir()) diff --git a/.kent/workflows/puber-release.manifest.json b/.kent/workflows/puber-release.manifest.json index 8ab3a03e..e40cf788 100644 --- a/.kent/workflows/puber-release.manifest.json +++ b/.kent/workflows/puber-release.manifest.json @@ -61,17 +61,17 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-puber-release-intent=63e4ed4498511b78b7802c93fbab1386a70638ce079e365ca78d151340d7e0c6", + "key": ".kent/scripts/tests/test-workflow-puber-release-intent=ff993baf14cbe5223bf47a61435106d3ea93223798b6715af9456bc2737ab65d", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-puber-release-publish=78d7eb977dc7fb42cabb6754bde369d35834fe43f205372d523d82d8b74e1cbb", + "key": ".kent/scripts/tests/test-workflow-puber-release-publish=2280a42193ae0ea8c41087a37d3cb184ddcc5dc9b1285421f2c88afd9ba59c98", "runtime_digest_required": true }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-release-cleanup=5bc70a16145e33a56506e1667b10829667f01cf9d2542f22221e5155be98f1cd", + "key": ".kent/scripts/tests/test-workflow-release-cleanup=3d53a0a7331771868572f4fcea2eb7349027410a086a670ebdb10ed6af5cc2a7", "runtime_digest_required": true }, { @@ -81,7 +81,7 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/tests/test-workflow-wait-github-release=b419183f87da69e2dac3db46605db9de8bf7b71d2fa0f5183533de8eaac885e1", + "key": ".kent/scripts/tests/test-workflow-wait-github-release=6575037baeeed00875ddbe7b8c554f63364ab5220ede5b250cf7fb783247ff44", "runtime_digest_required": true }, { @@ -91,7 +91,7 @@ }, { "kind": "source-sha256", - "key": ".kent/scripts/workflow-release-cleanup=7385543e8e27a143a110af13e17b28c137fb5f5c4a57bdcf3ecbe3f9988a1570", + "key": ".kent/scripts/workflow-release-cleanup=172aac182d716bf5a2422ac18c03911682782c98e2aca29589763016aaeaaee7", "runtime_digest_required": true }, { From c796f16656c54e3453e486e0eb03f09a689c0734 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 10:38:33 +0500 Subject: [PATCH 18/21] Fix main actions and paging item identity --- .../ui/uikit/component/PagingListState.kt | 15 +++- .../main/component/MainScreenComponent.kt | 4 +- .../puber/ui/feature/main/model/MainAction.kt | 2 + .../feature/main/toptabs/TopTabMainContent.kt | 6 +- .../kino/puber/ui/feature/main/vm/MainVM.kt | 6 +- .../ui/uikit/component/PagingColumnKeyTest.kt | 41 ++++++++++ .../MainScreenComponentContractTest.kt | 77 +++++++++++++++++++ 7 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 app/src/test/kotlin/com/kino/puber/core/ui/uikit/component/PagingColumnKeyTest.kt create mode 100644 app/src/test/kotlin/com/kino/puber/ui/feature/main/component/MainScreenComponentContractTest.kt diff --git a/app/src/main/java/com/kino/puber/core/ui/uikit/component/PagingListState.kt b/app/src/main/java/com/kino/puber/core/ui/uikit/component/PagingListState.kt index 903ec176..0852fe40 100644 --- a/app/src/main/java/com/kino/puber/core/ui/uikit/component/PagingListState.kt +++ b/app/src/main/java/com/kino/puber/core/ui/uikit/component/PagingListState.kt @@ -63,6 +63,7 @@ data class PagingListPageError( fun PagingColumn( modifier: Modifier = Modifier, pagingListState: PagingListState, + itemKey: (Item) -> Any, item: @Composable LazyItemScope.(index: Int, item: Item) -> Unit, listState: LazyListState = rememberLazyListState(), headers: LazyListScope.() -> Unit = {}, @@ -95,7 +96,12 @@ fun PagingColumn( } } headers() - items(pagingListState.items.size) { index -> + items( + count = pagingListState.items.size, + key = { index -> + resolvePagingColumnItemKey(pagingListState.items[index], itemKey) + }, + ) { index -> item(index, pagingListState.items[index]) } footer() @@ -121,4 +127,9 @@ fun PagingColumn( loadMoreAtEnd = loadMoreAtEnd, loadMoreAtStart = loadMoreAtStart, ) -} \ No newline at end of file +} + +internal fun resolvePagingColumnItemKey( + item: Item, + itemKey: (Item) -> Any, +): Any = itemKey(item) diff --git a/app/src/main/java/com/kino/puber/ui/feature/main/component/MainScreenComponent.kt b/app/src/main/java/com/kino/puber/ui/feature/main/component/MainScreenComponent.kt index c8ab894f..75fca9d3 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/main/component/MainScreenComponent.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/main/component/MainScreenComponent.kt @@ -57,7 +57,7 @@ import com.kino.puber.core.di.puberViewModel internal fun MainScreenComponent() { val vm = puberViewModel() val state by vm.collectViewState() - val onAction: (UIAction) -> Unit = remember { vm::onAction } + val onAction: (UIAction) -> Unit = remember(vm) { vm::onAction } when (state.navigationMode) { NavigationMode.SideDrawer -> DrawerMainContent( state = state, @@ -70,8 +70,6 @@ internal fun MainScreenComponent() { onAction = onAction, tabRouter = vm.tabRouter, tabAppRouterHolder = vm.tabAppRouterHolder, - onSearchClick = vm::onSearchClick, - onSettingsClick = vm::onSettingsClick, ) } } diff --git a/app/src/main/java/com/kino/puber/ui/feature/main/model/MainAction.kt b/app/src/main/java/com/kino/puber/ui/feature/main/model/MainAction.kt index 46bcd69e..214a313a 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/main/model/MainAction.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/main/model/MainAction.kt @@ -4,4 +4,6 @@ import com.kino.puber.core.ui.uikit.model.UIAction internal sealed class MainAction : UIAction { data class RefreshTab(val tab: MainTab) : MainAction() + data object SearchClicked : MainAction() + data object SettingsClicked : MainAction() } diff --git a/app/src/main/java/com/kino/puber/ui/feature/main/toptabs/TopTabMainContent.kt b/app/src/main/java/com/kino/puber/ui/feature/main/toptabs/TopTabMainContent.kt index b9a485d4..60b11136 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/main/toptabs/TopTabMainContent.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/main/toptabs/TopTabMainContent.kt @@ -56,8 +56,6 @@ internal fun TopTabMainContent( onAction: (UIAction) -> Unit, tabRouter: TabRouter, tabAppRouterHolder: TabAppRouterHolder, - onSearchClick: () -> Unit, - onSettingsClick: () -> Unit, ) { val tabRowFocus = remember { FocusRequester() } val contentFocus = remember { FocusRequester() } @@ -135,8 +133,8 @@ internal fun TopTabMainContent( onTabFocused = tabFocusState.onTabFocused, onTabClick = requestContentFocus, onTabContextMenu = { index -> contextMenuTabIndex = index }, - onSearchClick = onSearchClick, - onSettingsClick = onSettingsClick, + onSearchClick = { onAction(MainAction.SearchClicked) }, + onSettingsClick = { onAction(MainAction.SettingsClicked) }, modifier = Modifier .onFocusChanged { if (it.hasFocus) { diff --git a/app/src/main/java/com/kino/puber/ui/feature/main/vm/MainVM.kt b/app/src/main/java/com/kino/puber/ui/feature/main/vm/MainVM.kt index 75a4010c..1220584a 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/main/vm/MainVM.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/main/vm/MainVM.kt @@ -41,6 +41,8 @@ internal class MainVM( when (action) { is CommonAction.ItemSelected<*> -> onTabSelected(action.item as MainTab) is MainAction.RefreshTab -> onTabRefresh(action.tab) + MainAction.SearchClicked -> navigateToSearch() + MainAction.SettingsClicked -> navigateToSettings() else -> super.onAction(action) } } @@ -92,11 +94,11 @@ internal class MainVM( } } - fun onSearchClick() { + private fun navigateToSearch() { router.navigateTo(router.screens.search()) } - fun onSettingsClick() { + private fun navigateToSettings() { router.navigateTo(router.screens.deviceSettings()) } diff --git a/app/src/test/kotlin/com/kino/puber/core/ui/uikit/component/PagingColumnKeyTest.kt b/app/src/test/kotlin/com/kino/puber/core/ui/uikit/component/PagingColumnKeyTest.kt new file mode 100644 index 00000000..0c1c359c --- /dev/null +++ b/app/src/test/kotlin/com/kino/puber/core/ui/uikit/component/PagingColumnKeyTest.kt @@ -0,0 +1,41 @@ +package com.kino.puber.core.ui.uikit.component + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class PagingColumnKeyTest { + + @Test + fun itemKey_preservesIdentityAcrossReorderAndPayloadReplacement() { + val initialItems = listOf( + TestItem(id = "first", payload = "old first"), + TestItem(id = "second", payload = "old second"), + ) + val updatedItems = listOf( + TestItem(id = "second", payload = "new second"), + TestItem(id = "first", payload = "new first"), + ) + val itemKey: (TestItem) -> Any = TestItem::id + val initialKeys = initialItems.associate { item -> + item.id to resolvePagingColumnItemKey(item, itemKey) + } + + assertEquals( + listOf("second", "first"), + updatedItems.map { item -> resolvePagingColumnItemKey(item, itemKey) }, + ) + assertEquals( + initialKeys["first"], + resolvePagingColumnItemKey(updatedItems[1], itemKey), + ) + assertEquals( + initialKeys["second"], + resolvePagingColumnItemKey(updatedItems[0], itemKey), + ) + } + + private data class TestItem( + val id: String, + val payload: String, + ) +} diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/main/component/MainScreenComponentContractTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/main/component/MainScreenComponentContractTest.kt new file mode 100644 index 00000000..988f82e7 --- /dev/null +++ b/app/src/test/kotlin/com/kino/puber/ui/feature/main/component/MainScreenComponentContractTest.kt @@ -0,0 +1,77 @@ +package com.kino.puber.ui.feature.main.component + +import com.kino.puber.core.ui.navigation.AppRouter +import com.kino.puber.core.ui.navigation.PuberScreen +import com.kino.puber.core.ui.navigation.Screens +import com.kino.puber.core.ui.navigation.TabRouter +import com.kino.puber.data.preferences.NavigationPreferencesRepository +import com.kino.puber.ui.feature.main.model.MainAction +import com.kino.puber.ui.feature.main.model.MainUIMapper +import com.kino.puber.ui.feature.main.model.MainViewState +import com.kino.puber.ui.feature.main.vm.MainVM +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class MainScreenComponentContractTest { + + @Test + fun searchClicked_navigatesToSearchWithoutChangingMainState() { + val searchScreen = mockk() + val (vm, router, screens, tabRouter) = createViewModel(searchScreen = searchScreen) + val initialState = vm.testStateValue + + vm.onAction(MainAction.SearchClicked) + + verify(exactly = 1) { screens.search() } + verify(exactly = 1) { router.navigateTo(searchScreen) } + verify(exactly = 0) { screens.deviceSettings() } + verify(exactly = 0) { tabRouter.openTab(any()) } + assertEquals(initialState, vm.testStateValue) + vm.testCancelScope() + } + + @Test + fun settingsClicked_navigatesToDeviceSettingsWithoutChangingMainState() { + val settingsScreen = mockk() + val (vm, router, screens, tabRouter) = createViewModel(settingsScreen = settingsScreen) + val initialState = vm.testStateValue + + vm.onAction(MainAction.SettingsClicked) + + verify(exactly = 1) { screens.deviceSettings() } + verify(exactly = 1) { router.navigateTo(settingsScreen) } + verify(exactly = 0) { screens.search() } + verify(exactly = 0) { tabRouter.openTab(any()) } + assertEquals(initialState, vm.testStateValue) + vm.testCancelScope() + } + + private fun createViewModel( + searchScreen: PuberScreen? = null, + settingsScreen: PuberScreen? = null, + ): ViewModelFixture { + val router = mockk(relaxed = true) + val screens = mockk() + val tabRouter = mockk(relaxed = true) + every { router.screens } returns screens + searchScreen?.let { every { screens.search() } returns it } + settingsScreen?.let { every { screens.deviceSettings() } returns it } + val vm = MainVM( + router = router, + mainUIMapper = mockk(relaxed = true), + tabRouter = tabRouter, + navigationPreferencesRepository = mockk(relaxed = true), + ) + return ViewModelFixture(vm, router, screens, tabRouter) + } + + private data class ViewModelFixture( + val vm: MainVM, + val router: AppRouter, + val screens: Screens, + val tabRouter: TabRouter, + ) +} From ff29aff54ee098d21cd48dbd493f19acaf5a5796 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 10:49:11 +0500 Subject: [PATCH 19/21] Harden S10 regression test determinism --- .../puber/core/ui/uikit/component/PagingColumnKeyTest.kt | 5 +++++ .../main/component/MainScreenComponentContractTest.kt | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/app/src/test/kotlin/com/kino/puber/core/ui/uikit/component/PagingColumnKeyTest.kt b/app/src/test/kotlin/com/kino/puber/core/ui/uikit/component/PagingColumnKeyTest.kt index 0c1c359c..12c06fc1 100644 --- a/app/src/test/kotlin/com/kino/puber/core/ui/uikit/component/PagingColumnKeyTest.kt +++ b/app/src/test/kotlin/com/kino/puber/core/ui/uikit/component/PagingColumnKeyTest.kt @@ -1,6 +1,7 @@ package com.kino.puber.core.ui.uikit.component import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Test internal class PagingColumnKeyTest { @@ -20,6 +21,10 @@ internal class PagingColumnKeyTest { item.id to resolvePagingColumnItemKey(item, itemKey) } + assertNotEquals(initialItems[0].payload, updatedItems[1].payload) + assertNotEquals(initialItems[1].payload, updatedItems[0].payload) + assertEquals("new first", updatedItems[1].payload) + assertEquals("new second", updatedItems[0].payload) assertEquals( listOf("second", "first"), updatedItems.map { item -> resolvePagingColumnItemKey(item, itemKey) }, diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/main/component/MainScreenComponentContractTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/main/component/MainScreenComponentContractTest.kt index 988f82e7..f8abc9dc 100644 --- a/app/src/test/kotlin/com/kino/puber/ui/feature/main/component/MainScreenComponentContractTest.kt +++ b/app/src/test/kotlin/com/kino/puber/ui/feature/main/component/MainScreenComponentContractTest.kt @@ -9,14 +9,22 @@ import com.kino.puber.ui.feature.main.model.MainAction import com.kino.puber.ui.feature.main.model.MainUIMapper import com.kino.puber.ui.feature.main.model.MainViewState import com.kino.puber.ui.feature.main.vm.MainVM +import com.kino.puber.util.MainDispatcherExtension import io.mockk.every import io.mockk.mockk import io.mockk.verify import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension internal class MainScreenComponentContractTest { + companion object { + @JvmField + @RegisterExtension + val mainDispatcher = MainDispatcherExtension() + } + @Test fun searchClicked_navigatesToSearchWithoutChangingMainState() { val searchScreen = mockk() From 1ddd15f962c1ba90d0b17b370ef5c82131ab1ad0 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Tue, 25 Aug 2026 11:33:07 +0500 Subject: [PATCH 20/21] Baseline inherited Detekt findings --- config/detekt/detekt-baseline.xml | 81 +++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 4 deletions(-) diff --git a/config/detekt/detekt-baseline.xml b/config/detekt/detekt-baseline.xml index 53ea9d7a..906af433 100644 --- a/config/detekt/detekt-baseline.xml +++ b/config/detekt/detekt-baseline.xml @@ -1,6 +1,79 @@ - + - - - + + + BracesOnIfStatements:CollectionsScreenContent.kt$else + BracesOnIfStatements:CollectionsScreenContent.kt$if + BracesOnIfStatements:DeviceSettingsItems.kt$else + BracesOnIfStatements:FavoriteItemUIMapper.kt$FavoriteItemUIMapper$if + BracesOnIfStatements:HomeScreenContent.kt$else + BracesOnIfStatements:HomeScreenContent.kt$if + BracesOnIfStatements:MainSideMenuPreview.kt$else + BracesOnIfStatements:Paginator.kt$Paginator$else + BracesOnIfStatements:SectionRow.kt$else + BracesOnIfStatements:SectionRow.kt$if + BracesOnWhenStatements:BookmarksScreenContent.kt$-> + BracesOnWhenStatements:CollectionsScreenContent.kt$-> + BracesOnWhenStatements:DeviceSettingsVM.kt$DeviceSettingsVM$-> + BracesOnWhenStatements:HomeScreenContent.kt$-> + BracesOnWhenStatements:HomeVM.kt$HomeVM$-> + BracesOnWhenStatements:Paginator.kt$Paginator$-> + BracesOnWhenStatements:SectionRow.kt$-> + BracesOnWhenStatements:TvSafeButton.kt$-> + BracesOnWhenStatements:UpdatePromptVM.kt$UpdatePromptVM$-> + ComplexCondition:AppUpdateDownload.kt$AppUpdateDownloader$char.isLetterOrDigit() || char == '.' || char == '_' || char == '-' + CyclomaticComplexMethod:KinoPubApiClient.kt$KinoPubApiClient$suspend fun downloadUpdateAsset( url: String, targetFile: File, onProgress: (Int) -> Unit, ): Result<File> + LargeClass:KinoPubApiClient.kt$KinoPubApiClient + LargeClass:PlayerVM.kt$PlayerVM : PuberVM + LongMethod:CollectionCard.kt$@Composable internal fun CollectionCard( state: CollectionUIState, onClick: () -> Unit, modifier: Modifier = Modifier, ) + LongMethod:DeviceSettingsContent.kt$@Composable private fun DeviceSettingsList( state: DeviceSettingsState.Success, apiDomain: ApiDomainDialogState, onAction: (UIAction) -> Unit, ) + LongMethod:DeviceSettingsItems.kt$@Composable internal fun SettingListItem( setting: DeviceSettingUIModel.TypeList, isExpanded: Boolean, savingOptionId: Int?, onToggleExpand: () -> Unit, onOptionSelect: (Int) -> Unit, listState: LazyListState? = null, lazyItemIndex: Int = 0, ) + LongMethod:HeroCarousel.kt$@Composable fun HeroCarousel( items: List<HeroItemState>, onItemClick: (Int) -> Unit, modifier: Modifier = Modifier, onFocusedItemChanged: (Int) -> Unit = {}, ) + LongMethod:HeroCarousel.kt$@Composable private fun HeroItem( state: HeroItemState, onClick: () -> Unit, modifier: Modifier = Modifier, ) + LongMethod:HomeScreenContent.kt$@Composable private fun HomeContent( state: HomeViewState.Content, onAction: (UIAction) -> Unit, onHeroClick: (Int) -> Unit, onCollectionClick: (Int, String) -> Unit, lazyListState: LazyListState, ) + LongMethod:KinoPubApiClient.kt$KinoPubApiClient$private fun createHttpClient(): HttpClient + LongMethod:SectionRow.kt$@Composable private fun ContentSectionCards( state: SectionState.Content, isTargetRow: Boolean, shouldRequestInitialFocus: Boolean, rowHasFocusRef: BooleanArray, contentFocusRequester: FocusRequester, rowKey: String, onItemClick: (VideoItemUIState) -> Unit, onItemContextMenu: (VideoItemUIState) -> Unit, onItemFocused: (VideoItemUIState) -> Unit, onSectionFocused: () -> Unit, onLoadMore: () -> Unit, onShowAll: (() -> Unit)?, onRowEmpty: () -> Unit, ) + LongMethod:TopTabMainContent.kt$@OptIn(ExperimentalComposeUiApi::class) @Composable internal fun TopTabMainContent( state: MainViewState, onAction: (UIAction) -> Unit, tabRouter: TabRouter, tabAppRouterHolder: TabAppRouterHolder, ) + LongMethod:TvSafeButton.kt$@Composable internal fun TvSafeButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, primary: Boolean = false, ) + LongMethod:VideoItemGridDetails.kt$@Composable fun VideoDetailsDescription( modifier: Modifier, state: VideoDetailsUIState, descriptionMaxLines: Int = Int.MAX_VALUE, ) + LongMethod:VideoItemGridDetails.kt$@Composable private fun VideoDetailsPoster( modifier: Modifier, imageUrl: String, imageFallbackUrls: List<String>, ) + LongMethod:VideoItemHorizontal.kt$@Composable fun VideoItemHorizontal( modifier: Modifier = Modifier, state: VideoItemUIState, onClick: () -> Unit, onContextMenu: (() -> Unit)? = null, ) + LoopWithTooManyJumpStatements:DeviceInfoRepository.kt$DeviceInfoRepository$for + MagicNumber:AppUpdateDownload.kt$AppUpdateChecksum$0xff + MagicNumber:BookmarksScreenPreview.kt$6 + MagicNumber:BookmarksScreenPreview.kt$7 + MagicNumber:CollectionsScreenContent.kt$6 + MagicNumber:CollectionsScreenPreview.kt$9 + MagicNumber:CryptoPreferenceRepository.kt$CryptoPreferenceRepository$12 + MagicNumber:CryptoPreferenceRepository.kt$CryptoPreferenceRepository$128 + MagicNumber:CryptoPreferenceRepository.kt$CryptoPreferenceRepository$256 + MagicNumber:DeviceInfoRepository.kt$DeviceInfoRepository$2160 + MagicNumber:DeviceInfoRepository.kt$DeviceInfoRepository$3840 + MagicNumber:HeroCarousel.kt$50 + MagicNumber:HeroCarousel.kt$5000 + MagicNumber:IdGenerator.kt$IdGenerator$0x00FFFFFF + MagicNumber:LauncherScreen.kt$0xFF1A0E2E + MagicNumber:LauncherScreen.kt$LauncherScreen$0.8f + MagicNumber:PuberApp.kt$PuberApp$0.15 + MagicNumber:PuberApp.kt$PuberApp$1024 + MagicNumber:VideoItemUIMapper.kt$VideoItemUIMapper$10 + MagicNumber:VideoItemUIMapper.kt$VideoItemUIMapper$3600 + MagicNumber:VideoItemUIMapper.kt$VideoItemUIMapper$60 + MagicNumber:VideoItemUIMapper.kt$VideoItemUIMapper$86400 + MatchingDeclarationName:AppUpdateModels.kt$AvailableUpdate + MatchingDeclarationName:ConnectivityPlugin.kt$NoConnectivityException : IOException + MatchingDeclarationName:MainDispatcherRule.kt$MainDispatcherExtension : BeforeEachCallbackAfterEachCallback + MatchingDeclarationName:PreviewLorem.kt$Lorem + MatchingDeclarationName:TvContextMenuState.kt$TvContextMenuAction + MaxLineLength:DeviceSettingsRepository.kt$DeviceSettingsRepository$DeviceSettingType.MIXED_PLAYLIST -> kinoPubApiClient.updateDeviceSettings(deviceId, mixedPlaylist = optionId) + MaxLineLength:DeviceSettingsRepository.kt$DeviceSettingsRepository$DeviceSettingType.SERVER_LOCATION -> kinoPubApiClient.updateDeviceSettings(deviceId, serverLocation = optionId) + MaxLineLength:DeviceSettingsRepository.kt$DeviceSettingsRepository$DeviceSettingType.STREAMING_TYPE -> kinoPubApiClient.updateDeviceSettings(deviceId, streamingType = optionId) + MaxLineLength:ShowAllScreenContent.kt$var contextMenuItem by remember { mutableStateOf<com.kino.puber.core.ui.uikit.component.moviesList.VideoItemUIState?>(null) } + MaxLineLength:VideoItemHorizontal.kt$val + NestedBlockDepth:DeviceInfoRepository.kt$DeviceInfoRepository$override fun isHevcHardwareDecodingSupported(): Boolean + ReturnCount:AppUpdateDownload.kt$AppUpdateDownloader$suspend fun download( update: AvailableUpdate, onProgress: (AppUpdateDownload.Progress) -> Unit, ): AppUpdateDownload + ReturnCount:AppUpdateRepository.kt$AppUpdateRepository$private fun GitHubReleaseResponse.toAvailableUpdate(currentVersion: AppVersion): AvailableUpdate? + ReturnCount:AppVersion.kt$AppVersion.Companion$fun parse(raw: String): AppVersion? + ThrowingExceptionsWithoutMessageOrCause:LinkingDebugTree.kt$LinkingDebugTree$Throwable() + From c0b5ede1f0d61880705d47847f89551238f44b45 Mon Sep 17 00:00:00 2001 From: max rovkin Date: Mon, 31 Aug 2026 00:00:00 +0000 Subject: [PATCH 21/21] Fix integration with current master --- app/src/main/java/com/kino/puber/ui/feature/main/vm/MainVM.kt | 4 +++- config/detekt/detekt-baseline.xml | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/kino/puber/ui/feature/main/vm/MainVM.kt b/app/src/main/java/com/kino/puber/ui/feature/main/vm/MainVM.kt index 1220584a..a86b66cb 100644 --- a/app/src/main/java/com/kino/puber/ui/feature/main/vm/MainVM.kt +++ b/app/src/main/java/com/kino/puber/ui/feature/main/vm/MainVM.kt @@ -42,7 +42,7 @@ internal class MainVM( is CommonAction.ItemSelected<*> -> onTabSelected(action.item as MainTab) is MainAction.RefreshTab -> onTabRefresh(action.tab) MainAction.SearchClicked -> navigateToSearch() - MainAction.SettingsClicked -> navigateToSettings() + MainAction.SettingsClicked -> onSettingsClick() else -> super.onAction(action) } } @@ -98,6 +98,8 @@ internal class MainVM( router.navigateTo(router.screens.search()) } + fun onSettingsClick() = navigateToSettings() + private fun navigateToSettings() { router.navigateTo(router.screens.deviceSettings()) } diff --git a/config/detekt/detekt-baseline.xml b/config/detekt/detekt-baseline.xml index 906af433..638625cc 100644 --- a/config/detekt/detekt-baseline.xml +++ b/config/detekt/detekt-baseline.xml @@ -23,6 +23,7 @@ BracesOnWhenStatements:UpdatePromptVM.kt$UpdatePromptVM$-> ComplexCondition:AppUpdateDownload.kt$AppUpdateDownloader$char.isLetterOrDigit() || char == '.' || char == '_' || char == '-' CyclomaticComplexMethod:KinoPubApiClient.kt$KinoPubApiClient$suspend fun downloadUpdateAsset( url: String, targetFile: File, onProgress: (Int) -> Unit, ): Result<File> + CyclomaticComplexMethod:KinoPubTransferTransport.kt$KinoPubTransferTransport$suspend fun downloadUpdateAsset( url: String, targetFile: File, onProgress: (Int) -> Unit, ): Result<File> LargeClass:KinoPubApiClient.kt$KinoPubApiClient LargeClass:PlayerVM.kt$PlayerVM : PuberVM LongMethod:CollectionCard.kt$@Composable internal fun CollectionCard( state: CollectionUIState, onClick: () -> Unit, modifier: Modifier = Modifier, )