diff --git a/.github/workflows/auto_llama_cpp_update.yml b/.github/workflows/auto_llama_cpp_update.yml deleted file mode 100644 index cea7c33..0000000 --- a/.github/workflows/auto_llama_cpp_update.yml +++ /dev/null @@ -1,340 +0,0 @@ -name: Scan Native-Aligned llama.cpp Releases - -on: - schedule: - # Discovers new native releases and provides idempotent repair fallback. - # Once a pipeline starts, routine stage progression is event-driven. - - cron: '23 3 * * *' - # Each pipeline stage wakes the next scan the moment it completes, so once - # stable discovery selects an eligible native release, its later stages do - # not wait for another schedule or any manual continuation. A - # workflow_run event always executes the default-branch definition of this - # workflow, and the job gate below re-proves the owner identity of the run - # that woke it before any credential is reachable. - workflow_run: - workflows: - - Build Exact Bridge Candidate - - Qualify Exact Bridge Candidate - - Publish Exact Qualified Bridge Assets - types: [completed] - workflow_dispatch: - inputs: - release_channel: - description: Native release channel to inspect - required: true - default: stable - type: choice - options: - - stable - - development - native_release_tag: - description: Exact native tag; required for development, optional for stable - required: false - default: '' - type: string - dry_run: - description: Plan the next stable transition without dispatching it - required: false - default: false - type: boolean - -permissions: - contents: read - actions: read - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - NATIVE_REPO: leehack/llamadart-native - BRIDGE_REPO: leehack/llama-web-bridge - ASSETS_REPO: leehack/llama-web-bridge-assets - REQUESTED_CHANNEL: ${{ github.event_name == 'workflow_dispatch' && inputs.release_channel || 'stable' }} - REQUESTED_NATIVE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.native_release_tag || '' }} - DRY_RUN_REQUESTED: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} - -concurrency: - group: auto-stable-web-bridge-publication - cancel-in-progress: false - -jobs: - prepare_release_candidate: - name: Resolve and verify exact native release provenance - if: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == github.event.repository.default_branch && github.event.workflow_run.actor.login == github.repository_owner && github.event.workflow_run.triggering_actor.login == github.repository_owner) || (github.event_name == 'workflow_dispatch' && github.actor == github.repository_owner && github.triggering_actor == github.repository_owner) }} - runs-on: ubuntu-latest - outputs: - release_channel: ${{ steps.candidate.outputs.release_channel }} - release_count: ${{ steps.candidate.outputs.release_count }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - persist-credentials: false - - # The release modules are zero-dependency: no npm install, no cache. - - uses: actions/setup-node@v4 - with: - node-version: 24 - - - name: Prove the exact workflow continuation before environment use - if: ${{ github.event_name == 'workflow_run' }} - env: - GH_TOKEN: ${{ github.token }} - TRIGGER_RUN_ID: ${{ github.event.workflow_run.id }} - run: | - set -euo pipefail - if [[ ! "${TRIGGER_RUN_ID}" =~ ^[1-9][0-9]*$ ]]; then - echo "error: workflow_run id must be a positive integer" - exit 1 - fi - mkdir -p "${RUNNER_TEMP}/continuation-run" - gh api "repos/${BRIDGE_REPO}/actions/runs/${TRIGGER_RUN_ID}" \ - > "${RUNNER_TEMP}/continuation-run/run.json" - gh api "repos/${BRIDGE_REPO}/actions/runs/${TRIGGER_RUN_ID}/artifacts?per_page=100" \ - > "${RUNNER_TEMP}/continuation-run/artifacts.json" - workflow_path="$(jq -er '.path | select(type == "string")' \ - "${RUNNER_TEMP}/continuation-run/run.json")" - case "${workflow_path}" in - .github/workflows/bridge_candidate.yml) - artifact_name=exact-webgpu-bridge-dist - ;; - .github/workflows/bridge_qualification.yml) - artifact_name=qualification-attestation - ;; - .github/workflows/publish_assets.yml) - artifact_name=bridge-qualification-outcome - ;; - *) - echo "error: unsupported workflow_run continuation path ${workflow_path}" - exit 1 - ;; - esac - bridge_default="$(gh api "repos/${BRIDGE_REPO}" --jq '.default_branch')" - verification="$(node scripts/release/qualification.mjs verify-run \ - --run-json "${RUNNER_TEMP}/continuation-run/run.json" \ - --artifacts-json "${RUNNER_TEMP}/continuation-run/artifacts.json" \ - --run-id "${TRIGGER_RUN_ID}" \ - --workflow-path "${workflow_path}" \ - --head-branch "${bridge_default}" \ - --artifact-name "${artifact_name}" \ - --run-attempt 1)" - head_sha="$(jq -er '.head_sha' <<<"${verification}")" - run_status="$(gh api \ - "repos/${BRIDGE_REPO}/compare/${head_sha}...${bridge_default}" \ - --jq '.status')" - if [ "${run_status}" != "ahead" ] && [ "${run_status}" != "identical" ]; then - echo "error: workflow_run head is not reachable from ${bridge_default}" - exit 1 - fi - - - name: Resolve native-aligned candidate - id: candidate - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - mkdir -p "${RUNNER_TEMP}/native-candidates" release-candidates - gh api --paginate --slurp "repos/${NATIVE_REPO}/releases?per_page=100" \ - > "${RUNNER_TEMP}/native-release-pages.json" - jq 'add // []' "${RUNNER_TEMP}/native-release-pages.json" \ - > "${RUNNER_TEMP}/native-releases.json" - - if [ "${REQUESTED_CHANNEL}" = "development" ]; then - if [ -z "${REQUESTED_NATIVE_TAG}" ]; then - echo "error: development scans require an exact native_release_tag" - exit 1 - fi - jq -n --arg tag "${REQUESTED_NATIVE_TAG}" '[$tag]' \ - > "${RUNNER_TEMP}/native-tags.json" - elif [ "${REQUESTED_CHANNEL}" = "stable" ]; then - if [ -n "${REQUESTED_NATIVE_TAG}" ]; then - jq -n --arg tag "${REQUESTED_NATIVE_TAG}" '[$tag]' \ - > "${RUNNER_TEMP}/native-tags.json" - else - node scripts/release/orchestrator/cli.mjs \ - select-stable-native-backlog \ - --releases-json "${RUNNER_TEMP}/native-releases.json" \ - --output-json "${RUNNER_TEMP}/native-tags.json" - fi - else - echo "error: unsupported release channel ${REQUESTED_CHANNEL}" - exit 1 - fi - jq -r '.[]' "${RUNNER_TEMP}/native-tags.json" \ - > "${RUNNER_TEMP}/native-tags.txt" - node scripts/release/orchestrator/cli.mjs resolve-bridge-source \ - --repository . --head HEAD \ - --output-json "${RUNNER_TEMP}/bridge-source-identity.json" - bridge_source_sha="$(jq -er '.bridge_source_sha' \ - "${RUNNER_TEMP}/bridge-source-identity.json")" - bridge_build_sha="$(jq -er '.bridge_build_sha' \ - "${RUNNER_TEMP}/bridge-source-identity.json")" - while IFS= read -r NATIVE_TAG; do - if [ "${REQUESTED_CHANNEL}" = "stable" ]; then - if [[ ! "${NATIVE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[1-9][0-9]*)?$ ]]; then - echo "error: stable native release tag is not canonical" - exit 1 - fi - elif [[ ! "${NATIVE_TAG}" =~ ^b[0-9]+(-[1-9][0-9]*)?$ ]]; then - echo "error: development native release tag is not canonical" - exit 1 - fi - - candidate_dir="${RUNNER_TEMP}/native-candidates/${NATIVE_TAG}" - mkdir -p "${candidate_dir}" - jq --arg tag "${NATIVE_TAG}" ' - [.[] | select(.tag_name == $tag)] as $matches - | if ($matches | length) == 1 then $matches[0] - else error("native release tag must resolve to exactly one release") end - ' "${RUNNER_TEMP}/native-releases.json" \ - > "${candidate_dir}/native-release.json" - native_published_at="$(jq -er ' - .published_at - | select(type == "string") - | select(test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) - ' "${candidate_dir}/native-release.json")" - - for name in assets.json SHA256SUMS; do - asset_id="$(jq -er --arg name "${name}" ' - [.assets[]? | select(.name == $name)] as $matches - | if (($matches | length) == 1 - and ($matches[0].id | type) == "number" - and $matches[0].id > 0 - and (($matches[0].id | tostring) | test("^[1-9][0-9]*$"))) - then $matches[0].id - else error("native release asset must be unique with a positive id") end - ' "${candidate_dir}/native-release.json")" - gh api -H "Accept: application/octet-stream" \ - "repos/${NATIVE_REPO}/releases/assets/${asset_id}" \ - > "${candidate_dir}/${name}" - done - - git ls-remote --tags "https://github.com/${NATIVE_REPO}.git" \ - "refs/tags/${NATIVE_TAG}" "refs/tags/${NATIVE_TAG}^{}" \ - > "${candidate_dir}/native-tag-refs.txt" - native_tag_commit="$(node scripts/release/contract.mjs resolve-tag-commit \ - --ls-remote "${candidate_dir}/native-tag-refs.txt" \ - --tag "${NATIVE_TAG}")" - - provenance_json="release-candidates/${NATIVE_TAG}.json" - node scripts/release/orchestrator/cli.mjs scan-native \ - --manifest "${candidate_dir}/assets.json" \ - --native-release-tag "${NATIVE_TAG}" \ - --native-release-published-at "${native_published_at}" \ - --bridge-source-sha "${bridge_source_sha}" \ - --bridge-build-sha "${bridge_build_sha}" \ - --channel "${REQUESTED_CHANNEL}" \ - --output-json "${provenance_json}" - - upstream_tag="$(jq -er '.upstream_tag' "${provenance_json}")" - upstream_commit="$(jq -er '.upstream_commit' "${provenance_json}")" - manifest_sha256="$(jq -er '.native_manifest_sha256' "${provenance_json}")" - node scripts/release/contract.mjs validate-native-release \ - --manifest "${candidate_dir}/assets.json" \ - --checksums "${candidate_dir}/SHA256SUMS" \ - --release-json "${candidate_dir}/native-release.json" \ - --manifest-sha256 "${manifest_sha256}" \ - --native-release-tag "${NATIVE_TAG}" \ - --upstream-tag "${upstream_tag}" \ - --upstream-commit "${upstream_commit}" \ - --native-tag-commit "${native_tag_commit}" - done < "${RUNNER_TEMP}/native-tags.txt" - - release_count="$(wc -l < "${RUNNER_TEMP}/native-tags.txt" | tr -d ' ')" - if [ "${release_count}" -eq 0 ]; then - jq -n '[]' > release-candidates.json - else - jq -s 'sort_by(.native_release_published_at, .native_release_tag)' \ - release-candidates/*.json > release-candidates.json - fi - echo "release_channel=${REQUESTED_CHANNEL}" >> "${GITHUB_OUTPUT}" - echo "release_count=${release_count}" >> "${GITHUB_OUTPUT}" - - - name: Report development scan without orchestrating - if: ${{ env.REQUESTED_CHANNEL != 'stable' }} - run: | - set -euo pipefail - { - echo "### Development native scan" - echo - echo "Manual development scans only prepare exact candidate inputs. They never" - echo "dispatch a candidate build, a qualification run, or a publication." - echo - echo '```json' - cat release-candidates.json - echo '```' - } >> "${GITHUB_STEP_SUMMARY}" - - - name: Upload verified candidate provenance - uses: actions/upload-artifact@v4 - with: - name: bridge-release-candidate - if-no-files-found: error - path: release-candidates.json - - advance_stable_release: - name: Advance each exact stable publication by one stage - needs: prepare_release_candidate - if: ${{ needs.prepare_release_candidate.outputs.release_channel == 'stable' && needs.prepare_release_candidate.outputs.release_count != '0' && (github.event_name == 'schedule' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == github.event.repository.default_branch && github.event.workflow_run.actor.login == github.repository_owner && github.event.workflow_run.triggering_actor.login == github.repository_owner) || (github.event_name == 'workflow_dispatch' && github.actor == github.repository_owner && github.triggering_actor == github.repository_owner)) }} - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - # Reuse the established main-only publication environment and its single - # owner credential. No repository-level or newly named secret is accepted. - environment: - name: bridge-assets-publication - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - # This job holds the publication PAT, so it never installs packages; - # the release modules are zero-dependency. - - uses: actions/setup-node@v4 - with: - node-version: 24 - - - uses: actions/download-artifact@v4 - with: - name: bridge-release-candidate - - - name: Prove the dispatcher environment before exposing its credential - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh api "repos/${BRIDGE_REPO}/environments/bridge-assets-publication" \ - > "${RUNNER_TEMP}/publication-environment.json" - gh api "repos/${BRIDGE_REPO}/environments/bridge-assets-publication/deployment-branch-policies" \ - > "${RUNNER_TEMP}/publication-branch-policies.json" - node scripts/release/contract.mjs validate-environment \ - --environment-json "${RUNNER_TEMP}/publication-environment.json" \ - --branch-policies-json "${RUNNER_TEMP}/publication-branch-policies.json" - - - name: Advance the stable publication pipeline - env: - GH_TOKEN: ${{ github.token }} - # This existing environment-scoped PAT must identify the repository - # owner, read assets immutable-release governance, and have Actions - # write permission on the bridge repository. The orchestrator proves - # identity/governance live and fails closed if either capability is absent. - WEBGPU_BRIDGE_ASSETS_PAT: ${{ secrets.WEBGPU_BRIDGE_ASSETS_PAT }} - run: | - set -euo pipefail - dry_run_flag=() - if [ "${DRY_RUN_REQUESTED}" = "true" ]; then - dry_run_flag+=(--dry-run) - fi - node scripts/release/orchestrator/cli.mjs orchestrate-backlog \ - --provenance-list-json release-candidates.json \ - --workspace "${RUNNER_TEMP}/orchestrator" \ - --output-plan-json orchestration-plan.json \ - --step-summary-file "${GITHUB_STEP_SUMMARY}" \ - "${dry_run_flag[@]}" - - - name: Upload orchestration plan - if: ${{ always() }} - uses: actions/upload-artifact@v4 - with: - name: stable-release-orchestration-plan - if-no-files-found: ignore - path: orchestration-plan.json diff --git a/.github/workflows/bridge_candidate.yml b/.github/workflows/bridge_candidate.yml index d831b77..4e1de89 100644 --- a/.github/workflows/bridge_candidate.yml +++ b/.github/workflows/bridge_candidate.yml @@ -1,9 +1,7 @@ name: Build Exact Bridge Candidate -# The runs API never echoes a dispatch's inputs, so this deterministic run name -# is the only durable record of which exact pipeline attempt a run belongs to. -# scripts/release/orchestrator/run_names.mjs parses it byte-for-byte; changing the -# format without changing the parser orphans in-flight candidates. +# The runs API never echoes a dispatch's inputs, so this run name is the +# durable record of which release a run belongs to. run-name: bridge-candidate ${{ inputs.orchestrator_correlation_id }} source:${{ inputs.bridge_source_sha }} tag:${{ inputs.release_tag }} rebuild:${{ inputs.release_rebuild }} on: @@ -16,7 +14,7 @@ on: workflow_dispatch: inputs: orchestrator_correlation_id: - description: Required central-orchestrator correlation identifier + description: Correlation ID shared by the candidate, qualification and publish runs of one release, e.g. manual-v0.1.83 required: true type: string bridge_source_sha: diff --git a/.github/workflows/bridge_qualification.yml b/.github/workflows/bridge_qualification.yml index 2c8dde8..e571a61 100644 --- a/.github/workflows/bridge_qualification.yml +++ b/.github/workflows/bridge_qualification.yml @@ -1,7 +1,7 @@ name: Qualify Exact Bridge Candidate -# Parsed by scripts/release/orchestrator/run_names.mjs to bind this qualification to -# the exact candidate run it proves; the runs API never echoes dispatch inputs. +# Names the exact candidate run this qualification proves; the runs API never +# echoes dispatch inputs. run-name: bridge-qualification ${{ inputs.orchestrator_correlation_id }} candidate:${{ inputs.candidate_run_id }} on: @@ -11,13 +11,13 @@ on: # and uploads the canonical attestation as this run's single artifact so # publication can require an exact successful qualification run. # - # The orchestrator dispatches this with the same owner credential it uses for - # the candidate build. No maintainer ever produces or transports an - # attestation by hand, and this workflow holds no publication credential. + # The owner dispatches this after the candidate run succeeds. No maintainer + # produces or transports an attestation by hand, and this workflow holds no + # publication credential. workflow_dispatch: inputs: orchestrator_correlation_id: - description: Required central-orchestrator correlation identifier + description: Correlation ID shared by the candidate, qualification and publish runs of one release, e.g. manual-v0.1.83 required: true type: string candidate_run_id: diff --git a/.github/workflows/publish_assets.yml b/.github/workflows/publish_assets.yml index 3d0eebe..1bd38b7 100644 --- a/.github/workflows/publish_assets.yml +++ b/.github/workflows/publish_assets.yml @@ -1,17 +1,17 @@ name: Publish Exact Qualified Bridge Assets -# Parsed by scripts/release/orchestrator/run_names.mjs so a retry can prove whether -# this exact candidate/qualification/output publication already ran. +# Names the exact candidate, qualification and output of this publication; the +# runs API never echoes dispatch inputs. run-name: publish-assets ${{ inputs.orchestrator_correlation_id }} candidate:${{ inputs.candidate_run_id }} qualification:${{ inputs.qualification_run_id }} source:${{ inputs.bridge_source_sha }} tag:${{ inputs.release_tag }} rebuild:${{ inputs.release_rebuild }} on: - # This workflow is intentionally bridge-owned and non-reusable. A central - # orchestrator may dispatch it, but the environment gate must execute in this - # repository rather than in the caller's repository context. + # This workflow is intentionally bridge-owned and non-reusable: the + # environment gate must execute in this repository rather than in a caller's + # repository context. workflow_dispatch: inputs: orchestrator_correlation_id: - description: Required central-orchestrator correlation identifier + description: Correlation ID shared by the candidate, qualification and publish runs of one release, e.g. manual-v0.1.83 required: true type: string bridge_source_sha: @@ -48,7 +48,7 @@ on: default: leehack/llama-web-bridge-assets type: string publish_approved: - description: Explicit approval recorded by the dispatching orchestrator + description: Owner's explicit approval to publish this exact candidate; must be true required: true type: boolean candidate_run_id: diff --git a/AGENTS.md b/AGENTS.md index a1d41f4..f4907dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,38 +105,14 @@ GGUFs or smoke artifacts. `bridge-candidate-prequalification`. Confirm the assertion first with `gh api repos/leehack/llama-web-bridge-assets/immutable-releases`. - Automated qualification: `.github/workflows/bridge_qualification.yml` - - Owner-dispatched by the orchestrator with the exact candidate run ID and + - Owner-dispatched with the exact candidate run ID and the candidate's correlation ID; no maintainer-supplied attestation input exists. It uploads one `qualification-attestation` artifact using repository access and no PAT. -- Orchestrator: `.github/workflows/auto_llama_cpp_update.yml` - - A candidate run reserves its output tag for other pipelines unless its - correlation names another governed build identity and none of that - correlation's candidate, qualification or publication runs is in flight. - - The resolver uses full default-branch history to keep the exact source SHA - that executes a new candidate separate from the newest first-parent commit - that changed governed runtime/build inputs. `AGENTS.md`, `README.md`, and - `CONTRIBUTING.md` are in `ORCHESTRATION_ONLY_PATHS` and `docs/` in - `ORCHESTRATION_ONLY_PREFIXES` in `scripts/release/orchestrator/cli.mjs`, - so docs-only commits never advance the build identity; every unclassified - new path is governed by default. - - Every dispatch sends exactly the target workflow's declared - `workflow_dispatch` inputs (`requireExactDispatchInputs`) at the exact - default-branch `--ref` after a live `immutable-releases` governance read; - duplicate in-flight or successful runs for one stage fail closed, and a - run-name readback follows each dispatch. - - Run recovery accepts only an exact supported workflow path plus its - deterministic `display_title`, owner actor/triggering actor, first attempt, - repository, and branch. GitHub exposes a workflow's rendered `run-name` in - the run record's `name` field, so that field is not treated as the static - workflow identity. A search at GitHub's 1,000-result cap is split into - closed time windows; a saturated one-second window or ambiguous result - fails closed. - - The dispatch job reuses the `bridge-assets-publication` environment and its - `WEBGPU_BRIDGE_ASSETS_PAT`, which must identify the repository owner, read - assets immutable-release governance, and hold Actions write permission on - `leehack/llama-web-bridge`. The orchestrator proves the owner identity and - the governance read live and fails closed if either is absent. +- Releases are manual: the owner dispatches candidate, qualification, then + publish, with the inputs in + [CONTRIBUTING.md](CONTRIBUTING.md#publish-process). No workflow dispatches + another, so each stage must verify every input it consumes on its own. - CI reliability contract: `scripts/ci/verify_ci_reliability.mjs` - Checks publication-safety invariants, not wording: it parses the workflows with `yaml` and reads commands from resolved `run` scripts. It covers @@ -178,7 +154,7 @@ GGUFs or smoke artifacts. `immutable-publication-unverified` outcome; publication never deletes, retags, overwrites, or otherwise repairs the release. -### Immutable Automation Baseline +### Immutable Release Baseline The historical `v0.1.38` release is not repaired or reused. The verified immutable baseline is: @@ -188,11 +164,6 @@ immutable baseline is: - `orchestrator_correlation_id`: `kanban:t_7f112b91:web-v0.1.39` - `assets_immutable_releases_enabled`: `true` -Daily backlog selection starts after native `v0.2.0-1`, published at -`2026-08-25T08:57:12Z`. A scan starts a new candidate only for the newest -scanned stable native release; each candidate gets its own exact -`candidate_run_id`/`attestation_run_id` pair. - ## Change Boundaries - Keep runtime bridge source code in `js/src/` and `src/`. The @@ -214,14 +185,6 @@ scanned stable native release; each candidate gets its own exact `scripts/ci/verify_ci_reliability.mjs` fails on a Python interpreter or package tool, a `*.py` script, a python shell, a Python image, or a `setup-python`/`setup-uv` step. -- `scripts/release/orchestrator/cli.mjs` is the orchestrator's CLI entry; the - state machine lives in the `scripts/release/orchestrator/.mjs` - modules it imports, tested by `tests/release/orchestrator/_test.mjs` - suites that share `tests/release/orchestrator/fixtures.mjs`. List a new module - in `ORCHESTRATION_ONLY_PATHS` in `cli.mjs` and in `TOOLING` in - `scripts/ci/ci_scope.mjs`; `scripts/ci/verify_ci_reliability.mjs` fails on a module - `cli.mjs` does not import. The deleted Python orchestrator's paths stay in - both lists. - Keep publishing logic in workflow only. - Do not edit assets repository files from here outside publish flow. - C++ exception catching is enabled only for the functions listed in diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6cac2a3..b1e55a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -356,7 +356,6 @@ query strings, and fragments before printing the location. current when changing `.github/workflows/ci.yml`, `.github/workflows/bridge_candidate.yml`, `.github/workflows/publish_assets.yml`, - `.github/workflows/auto_llama_cpp_update.yml`, `.github/workflows/bridge_qualification.yml`, or the model pins in `scripts/release/qualification.mjs`. It checks permissions, environment gates, PAT handling, pins, fail-closed guards, that CI runs the contract tests, and @@ -416,85 +415,15 @@ query strings, and fragments before printing the location. - Preserve `llama_cpp.version` as the default ordinary CI/development build pin. It holds exactly one upstream tag in either channel, stable `vMAJOR.MINOR.PATCH` or development `bNNNN`; `scripts/ci/verify_ci_reliability.mjs` - rejects every other form. Exact release publication receives upstream identity - from the orchestrator and must not require a bridge pin PR. + rejects every other form. Exact releases take upstream identity from their + dispatch inputs and must not require a bridge pin PR. - Preserve `emsdk.version` as the single compiler source for CI and publish. Both workflows must verify the active `emcc` version, and published manifests must record that verified identity. -- Main-branch and PR CI never dispatch publication. Stable release discovery - prepares an ordered `release-candidates.json` backlog for every - stable native release after the immutable native `v0.2.0-1` / Web-assets - `v0.1.39` baseline and invokes `scripts/release/orchestrator/cli.mjs` to - idempotently advance candidate, automated qualification, and publication - stages. Each successful stage wakes an immediate `workflow_run` continuation; - the daily schedule discovers new native releases and is the idempotent repair - fallback. The scan downloads - every selected `assets.json` and `SHA256SUMS` by - unique release-asset ID and runs the complete native release/tag/inventory - contract first. That contract accepts native `hook_contract_version` 1 and 2 - and fails on any other; contract 2's `backend-*` sidecar archives (such as - Windows x64 CUDA) must be named - `llamadart-native----.tar.gz` and are - checksum-verified like every other artifact. Each exact pipeline advances by - at most one stage per scan, and the complete backlog is visited so an older - in-flight gate does not starve a newer candidate. Publication remains ordered - by native release time, so output tags must follow the same order: an older native with no pipeline for - the current build identity is `superseded` when a newer native release is in - the scan or a newer native's pipeline still claims an output tag. Where its - qualification or publication would be dispatched, an older pipeline bound to - an output tag at or above one a newer native's pipeline claims is - `superseded` too. The claim comes from the candidate run name, so a targeted - `native_release_tag` scan of an older native obeys the same order. A claim - of a native outside the scan counts only while that native is a published, - non-draft stable release whose `assets.json` digest matches the claim's - correlation; otherwise the older native is `blocked`, naming the claim. - An entry without its own exact publication is `superseded` when a published - stable asset release's `Native:` marker names a newer native release (version, - then rebuild); the newest native release in a scan is `blocked` instead. - A proven candidate whose publication files other than `manifest.json` are - byte-identical to the newest published release for the same native release - and native manifest digest is `satisfied_by_identical_release`: nothing - further is dispatched and its output tag is released to later pipelines in - the same scan. The comparison runs only where a qualification or publication - would otherwise be dispatched, never while one of the correlation's runs is - in flight, and never across native alignments, with one exception. A - candidate whose output tag a published release has reached can never publish, - so it is never retried. Unless an identical release of its own native - satisfies it as above, it is `superseded`, without a byte comparison, when a - newer native release is in the scan. The newest scanned native is instead - `satisfied_by_identical_release` when its publication files other than - `manifest.json` match that newest release, published for another native - release in the same scan. When they differ, a recovery pipeline replaces it: - the same provenance under its correlation with `-build-` replaced by `-rN-`, - which binds a fresh output tag above every published tag and live claim and - runs a new first-attempt candidate, qualification and publication. The - stranded pipeline's runs, artifacts and attestation stay as they are, and - once a later generation publishes, it settles the stranded one whatever its - bytes. A recovery pipeline overtaken in turn moves to the next generation; an - overtaken generation 3 is `blocked` instead. When no native release in the - scan published the overtaking release, its bytes cannot be compared, so the - pipeline is `blocked` until a scan that includes that native release, such - as the daily backlog scan, compares them. - Manual `development` scans stay scan-only: they resolve exact `bNNNN` - provenance and report it, and the orchestrator refuses non-stable provenance. - A failed candidate or qualification is never retried automatically; after diagnosis, - a maintainer may deliberately dispatch one new first-attempt run with the same - exact binding, and a later unique success supersedes the recorded failed run. - On the normal path, a successful candidate is immediately followed by hosted - ASR/TTS qualification, then by publication after the exact attestation is - verified. Run recovery paginates each exact workflow's filtered - history with stable-count checks and splits searches at GitHub's 1,000-result - cap into closed time windows, so an older waiting pipeline is not lost as - later workflow history grows. -- The full-history scheduled resolver records both the exact default-branch - source SHA that will execute a new candidate and the newest first-parent - commit that changed governed runtime/build inputs. Correlation uses the latter: - non-build orchestration/qualification/publication/CI workflow, verification, - test, and documentation-only commits preserve a verified terminal release. - The candidate workflow, manifest/release-contract/compiler inputs, and every - other new path are governed by default and require a new candidate plus - automated qualification. Bridge source changes still - require ordinary PRs; release orchestration never changes `llama_cpp.version`. +- Main-branch and PR CI never dispatch publication; releases are dispatched + by hand (see [Publish Process](#publish-process)). A failed candidate or + qualification is never rerun; after diagnosis, dispatch one new first-attempt + run with the same inputs. Releasing never changes `llama_cpp.version`. - Preserve `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24` in CI and publish workflows so GitHub Action runtime changes are detected before they become mandatory. - Upload state-persistence smoke diagnostics only on failure; successful CI runs @@ -504,20 +433,72 @@ query strings, and fragments before printing the location. ## Publish Process -Use `.github/workflows/publish_assets.yml` only after the publication approval -assertion is backed by the live `bridge-assets-publication` policy. The -bridge-owned orchestrator dispatches this -bridge-owned, non-reusable workflow with a required correlation ID, exact bridge SHA, -upstream tag/commit, native release tag plus `assets.json` SHA-256, -output release tag/rebuild, distinct required `candidate_run_id` and -`qualification_run_id`, and assets repository. - -Immutable releases must already be enabled on the assets repository before the -candidate is dispatched. Confirm it with -`gh api repos/leehack/llama-web-bridge-assets/immutable-releases`, then dispatch -`bridge_candidate.yml` with `assets_immutable_releases_enabled=true`; the -candidate cannot read that setting itself and fails closed on the assertion. -Publication downloads the exact run-owned prequalification record and binds its +Releases are manual: the repository owner dispatches three workflows from +`main`, in order, each after the previous run succeeds. Every stage is +first-attempt-only, so after a failure dispatch a new run instead of rerunning. +Use one correlation ID for all three, 1-128 characters of `A-Za-z0-9._:-` +starting alphanumeric, such as `manual-v0.1.83`. The input keeps its historical +name `orchestrator_correlation_id` because published manifests and release +notes record it. + +Gather the inputs. Use the newest stable `llamadart-native` release: the +candidate and publish workflows reject a development (`bNNNN`) native tag, but +nothing checks that native releases are published in order. + +```bash +NATIVE_TAG=v0.8.0-1 +gh release download "$NATIVE_TAG" -R leehack/llamadart-native -p assets.json -O assets.json +jq -r '.llama_cpp_tag, .llama_cpp_commit' assets.json # upstream_tag, upstream_commit +shasum -a 256 assets.json # native_manifest_sha256 +git ls-remote https://github.com/leehack/llama-web-bridge refs/heads/main # bridge_source_sha +gh api repos/leehack/llama-web-bridge-assets/releases --jq '.[].tag_name' \ + | sort -V | tail -1 # release_tag: the next patch above this +gh api repos/leehack/llama-web-bridge-assets/immutable-releases # must be enabled +``` + +1. Build Exact Bridge Candidate: + + ```bash + gh workflow run bridge_candidate.yml -R leehack/llama-web-bridge --ref main \ + -f orchestrator_correlation_id=manual-v0.1.83 \ + -f bridge_source_sha=
\ + -f upstream_tag= -f upstream_commit= \ + -f native_release_tag="$NATIVE_TAG" -f native_manifest_sha256= \ + -f release_tag=v0.1.83 -f release_rebuild=0 \ + -f assets_immutable_releases_enabled=true + ``` + +2. Qualify Exact Bridge Candidate, with the candidate's run ID: + + ```bash + gh workflow run bridge_qualification.yml -R leehack/llama-web-bridge --ref main \ + -f orchestrator_correlation_id=manual-v0.1.83 -f candidate_run_id= + ``` + +3. Publish Exact Qualified Bridge Assets, with the step 1 inputs except + `assets_immutable_releases_enabled`, plus both run IDs: + + ```bash + gh workflow run publish_assets.yml -R leehack/llama-web-bridge --ref main \ + -f orchestrator_correlation_id=manual-v0.1.83 \ + -f bridge_source_sha=
\ + -f upstream_tag= -f upstream_commit= \ + -f native_release_tag="$NATIVE_TAG" -f native_manifest_sha256= \ + -f release_tag=v0.1.83 -f release_rebuild=0 \ + -f candidate_run_id= -f qualification_run_id= \ + -f publish_approved=true + ``` + +`gh run list -R leehack/llama-web-bridge -w -L 1` shows a run's +ID. Each stage re-verifies everything it consumes: the candidate resolves the +upstream and native tags and checks the native `assets.json` digest and +inventory; qualification proves the candidate run and that its manifest carries +the same correlation ID; publication re-proves both runs and binds the +attestation to every input above. + +The candidate cannot read the assets repository's immutable-release setting, so +it fails closed unless `assets_immutable_releases_enabled=true` and records the +assertion. Publication downloads the exact run-owned prequalification record and binds its explicit boolean assertion to the candidate and run identities before it re-proves the real state through the same endpoint and before it pushes, requiring the exact `{enabled, enforced_by_owner}` shape with `enabled` @@ -533,8 +514,8 @@ readback release ID, and the exact published artifact digests. A failure is repo `immutable-publication-unverified`. An incomplete published release is never filled in; the release is never deleted, retagged, overwritten, or repaired. -The orchestrator must set `publish_approved=true` only after live policy -verification. Publication remains blocked until +`publish_approved=true` is the owner's explicit approval; the workflow still +validates the live environment policy itself. Publication remains blocked until repository administrators separately create `bridge-assets-publication`, disable administrator bypass, restrict the custom deployment branch policy to `main`, and store the assets PAT as an environment-scoped secret. The solo-maintainer @@ -568,28 +549,18 @@ redispatching a new publication run against the same `candidate_run_id` and comes from the candidate run, so it stays stable. A different candidate must never target an existing output tag. -The event-driven orchestrator and its scheduled repair fallback reuse that same existing secret only from a -`bridge-assets-publication` environment job; it does not require an -`ORCHESTRATOR_DISPATCH_TOKEN`. For automatic progression, the owner-bound token -must retain Actions write permission on `leehack/llama-web-bridge` in addition -to the assets-repository permissions publication already requires. The job token -validates the environment policy first; the orchestrator then proves the PAT's -owner identity and live immutable-release governance before dispatch. It adds -`assets_immutable_releases_enabled=true` only from that governance response and -adds `publish_approved=true` only after the live publication-environment policy -passes. An already-published noop still resolves the exact assets tag commit, -validates independent release reads by tag and ID, and validates the signed -`gh release verify --format json` attestation against every downloaded asset -digest. Manual runs require both the initiating actor and triggering actor to be -the repository owner before either workflow job starts, preventing a -collaborator from using the environment PAT as a confused deputy. Trusted -default-branch schedule events remain automatic; `workflow_run` continuations -also require a successful stage run on the default branch with owner actor and -triggering actor before either job starts. +`WEBGPU_BRIDGE_ASSETS_PAT` needs only what publication uses: read access to the +provenance repositories, plus write access and Administration read (for the +`immutable-releases` check) on `leehack/llama-web-bridge-assets`. +No workflow dispatches another, so it needs no Actions permission on +`leehack/llama-web-bridge`. An already-published noop still resolves the exact +assets tag commit, validates independent release reads by tag and ID, and +validates the signed `gh release verify --format json` attestation against +every downloaded asset digest. New bridge asset tags are npm-shaped `vMAJOR.MINOR.PATCH` with rebuild `0`: -`selectNextReleaseTarget` skips a published or claimed version by taking -the next free patch version, and `scripts/release/contract.mjs validate-release` rejects +take the next free patch version above the newest published tag. +`scripts/release/contract.mjs validate-release` rejects a new `-N` tag in the candidate and publish workflows, because npm orders `vMAJOR.MINOR.PATCH-N` as a prerelease. Earlier `-N` bridge tags, the native forms `vMAJOR.MINOR.PATCH-N`, `bNNNN` and `bNNNN-N`, historical @@ -619,8 +590,5 @@ OS/architecture, exact Emscripten version, resolved llama.cpp commit, and build script/CMake/patch inputs. ccache also checks compiler contents, source inputs, and compile flags. Every selected build still links fresh artifacts and runs every CI browser smoke. Candidate and publication workflows do not consume -this cache or this change selector, so `scripts/ci/ci_scope.mjs` is listed in -`ORCHESTRATION_ONLY_PATHS` in `scripts/release/orchestrator/cli.mjs`. List any -new CI-only script there too: an unclassified path is governed by default and -advances the release build identity. Track follow-up work in +this cache or this change selector. Track follow-up work in [llamadart issue #532](https://github.com/leehack/llamadart/issues/532). diff --git a/README.md b/README.md index e553a87..aa29779 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ node scripts/ci/verify_ci_reliability.mjs ``` The reliability contract checks the publication-safety invariants of the CI, -candidate, qualification, publication, and orchestration workflows. It parses +candidate, qualification, and publication workflows. It parses the workflows rather than matching their wording, so rewording a step or its comments never fails it: @@ -258,7 +258,7 @@ comments never fails it: job may `continue-on-error` (only the publication ref mutation, whose outcome a live re-query classifies); - `WEBGPU_BRIDGE_ASSETS_PAT` is the only secret in any workflow, referenced only - by the publication and orchestration workflows and only through a step's + by the publication workflow and only through a step's `env`; in the publication workflow every step that binds it starts with `set -euo pipefail` and a fail-closed empty-token guard, and never prints it; - publication is dispatch-only, requires `publish_approved=true`, serializes @@ -273,11 +273,8 @@ comments never fails it: ID, verifies the attestation in both jobs with the trusted validators, proves immutable-release governance before any ref mutation, reads the release back as immutable and attested, and never deletes, retags, or overwrites a release; -- `.github/workflows/auto_llama_cpp_update.yml` fetches provenance by release - asset ID, gates every job on the repository owner, uses the PAT only inside the - validated publication environment, dispatches only through the orchestrator, - and never opens a PR or pushes; ordinary CI never dispatches publication or - holds `actions: write`; +- no workflow dispatches another or holds `actions: write`; releases are + dispatched by hand; - CI runs the state-persistence, multimodal, grammar, and next-token-score browser smokes. @@ -338,8 +335,8 @@ The harness: The automated qualification workflow performs the same provenance checks, downloads and digest-verifies every pinned input, runs the harness against the exact candidate artifact, and uploads one canonical `qualification-attestation` -artifact. The orchestrator advances publication only after that exact -first-attempt qualification run succeeds. No maintainer creates, transports, or +artifact. Publication accepts only that exact successful first-attempt +qualification run. No maintainer creates, transports, or submits an attestation, and the qualification workflow holds no publication PAT. `scripts/smoke/speech_to_text.mjs` and @@ -379,27 +376,13 @@ Automated qualification workflow: - `.github/workflows/bridge_qualification.yml` -Trigger modes: - -- The bridge-owned orchestrator dispatches candidate build, automated - qualification, and publication through GitHub's API using the repository - owner's existing authorized identity; owner checks reject other dispatch - actors. A successful candidate, qualification, or publication run triggers an - immediate idempotent continuation scan. The daily schedule performs initial - native-release discovery and repairs missed or delayed event delivery. The - stage workflows are deliberately not reusable - because reusable job environments execute in the caller repository's context. - -The scheduled resolver keeps the current default-branch source SHA separate -from the newest main-line commit that changed governed runtime/build inputs. -Non-build orchestration/qualification/publication/CI workflow, verification, -test, and documentation-only commits therefore preserve verified pipeline -state. The candidate workflow and any runtime/build input change create a new -correlation and must produce a new automatically qualified candidate. - -There is no push, tag, or ordinary-CI publication trigger. The schedule and -`workflow_run` events wake the orchestrator; only its exact `workflow_dispatch` -calls start a build, qualification, or publication stage. +Releases are manual. The repository owner dispatches the candidate, +qualification, and publication workflows in that order; +[CONTRIBUTING.md](CONTRIBUTING.md#publish-process) lists each one's inputs. +Each stage refuses a non-owner dispatcher and verifies its inputs itself. The +stage workflows are deliberately not reusable because reusable job +environments execute in the caller repository's context. There is no push, +tag, schedule, `workflow_run`, or ordinary-CI trigger for any stage. Immutable releases must already be enabled on `leehack/llama-web-bridge-assets` before a candidate is dispatched. `bridge_candidate.yml` requires @@ -430,18 +413,17 @@ assets; publication never deletes, retags, overwrites, or repairs it. Required externally configured credentials: -- `WEBGPU_BRIDGE_ASSETS_PAT` (read access to provenance repositories and write - access to `leehack/llama-web-bridge-assets`, stored only in the publication - environment). This is the only externally configured publication credential. +- `WEBGPU_BRIDGE_ASSETS_PAT` (read access to provenance repositories, plus + write access and Administration read on `leehack/llama-web-bridge-assets`, + stored only in the publication environment). This is the only externally configured publication credential. -Every request supplies a required orchestrator correlation ID, the exact bridge +Every request supplies a required correlation ID, the exact bridge source SHA, upstream llama.cpp tag/commit, native release tag plus `assets.json` SHA-256, output release tag/rebuild, a distinct required `candidate_run_id` and `qualification_run_id`, and an exact assertion of the fixed assets repository. Dispatch values enter shell scripts only through environment variables and -quoted expansions. The orchestrator sets `publish_approved=true` only after its -live publication-environment policy check succeeds; no human continuation is -part of the release pipeline. +quoted expansions. The owner sets `publish_approved=true` explicitly, and the +workflow still validates the live publication-environment policy itself. Publication is deliberately blocked until repository administrators externally configure `bridge-assets-publication` with administrator bypass disabled, @@ -460,7 +442,9 @@ each PAT-bearing step fails closed unless the credential is non-empty and does not print its value. Missing or weakened bypass, branch, credential, or PAT guard protection therefore fails closed. -The workflow verifies that bridge source is already merged, all upstream/native +The candidate and publish workflows accept only a stable native release +(`vMAJOR.MINOR.PATCH` or `vMAJOR.MINOR.PATCH-N`), never a development `bNNNN` +one. The workflow verifies that bridge source is already merged, all upstream/native tag and commit identities match, the native manifest checksum matches, and the new release advances its own channel history without collision or rollback. Stable releases compare only with stable history; development releases and @@ -481,7 +465,7 @@ the producing qualification workflow path, run ID, first attempt, and source SHA -- once before entering the privileged job and again inside it. The published schema-v2 manifest carries the release tag, capabilities, -bridge/upstream/native commits, orchestrator correlation ID, the candidate run +bridge/upstream/native commits, correlation ID, the candidate run ID/URL, per-artifact SHA-256 values, `qualification_gates` recording `state_persistence` and `multimodal` as `passed` with `speech_to_text` and `text_to_speech` as `required-automated-qualification`, and `unproven_capabilities` @@ -509,13 +493,12 @@ publication workflow run using the same `candidate_run_id` and overwrite an earlier one under the same output tag. New bridge asset release tags are npm-shaped `vMAJOR.MINOR.PATCH` with -`release_rebuild` `0`. When the next version is already published or claimed by -an unfinished pipeline, the orchestrator takes the next free patch version; it -never appends `-N`, which npm orders as a prerelease below the plain version. -A new tag is never lower than a published tag or a live claim; a claim released -without publication stops counting, so its version may be taken again or stay -unused. `scripts/release/contract.mjs validate-release`, which the candidate and publish -workflows run, rejects a new tag with a rebuild suffix. +`release_rebuild` `0`: take the next free patch version above the newest +published tag, never a `-N` suffix, which npm orders as a prerelease below the +plain version. `scripts/release/contract.mjs validate-release`, which the +candidate and publish workflows run, rejects a new tag with a rebuild suffix, +and publication rejects a tag that already exists or does not advance its +channel history. Tags published before this rule, such as `v0.1.47-1`, and the shared native forms (`vMAJOR.MINOR.PATCH-N`, `bNNNN`, `bNNNN-N`) stay readable. Historical @@ -542,6 +525,6 @@ Note: CDN pinning fundamentally relies on git tags in the assets repo. - `CONTRIBUTING.md`: setup, build, the runnable smoke invocations and their pins, workflow guardrails, and the publish process -- `AGENTS.md`: agent PR workflow, orchestrator invariants stated nowhere else, +- `AGENTS.md`: agent PR workflow, release invariants stated nowhere else, the `js/src/`-source-vs-`js/`-generated rule, cross-repo handoff, and regression smoke guidance diff --git a/scripts/ci/ci_scope.mjs b/scripts/ci/ci_scope.mjs index 100b639..8810bd5 100644 --- a/scripts/ci/ci_scope.mjs +++ b/scripts/ci/ci_scope.mjs @@ -14,27 +14,6 @@ import process from 'node:process'; export const TOOLING = new Set([ 'scripts/release_publication_state.py', 'scripts/release_publication_state_test.py', 'scripts/release/publication_state.mjs', - 'scripts/release/orchestrator/model.mjs', 'scripts/release/orchestrator/native.mjs', - 'scripts/release/orchestrator/planner.mjs', 'scripts/release/orchestrator/release_tags.mjs', - 'scripts/release/orchestrator/run_names.mjs', 'scripts/release/orchestrator/transport.mjs', - 'scripts/release/orchestrator/workflow_runs.mjs', - 'scripts/release/orchestrator/asset_releases.mjs', 'scripts/release/orchestrator/cli.mjs', - 'scripts/release/orchestrator/driver.mjs', 'scripts/release/orchestrator/stage_proofs.mjs', - 'scripts/stable_release_orchestrator.py', 'scripts/stable_release_orchestrator_test.py', - 'scripts/release_orchestrator_asset_releases.py', 'scripts/release_orchestrator_asset_releases_test.py', - 'scripts/release_orchestrator_driver.py', 'scripts/release_orchestrator_driver_test.py', - 'scripts/release_orchestrator_driver_backlog_test.py', - 'scripts/release_orchestrator_driver_identical_release_test.py', - 'scripts/release_orchestrator_driver_publication_test.py', - 'scripts/release_orchestrator_fixtures_test.py', - 'scripts/release_orchestrator_model.py', - 'scripts/release_orchestrator_native.py', 'scripts/release_orchestrator_native_test.py', - 'scripts/release_orchestrator_planner.py', 'scripts/release_orchestrator_planner_test.py', - 'scripts/release_orchestrator_release_tags.py', 'scripts/release_orchestrator_release_tags_test.py', - 'scripts/release_orchestrator_run_names.py', 'scripts/release_orchestrator_run_names_test.py', - 'scripts/release_orchestrator_stage_proofs.py', - 'scripts/release_orchestrator_transport.py', 'scripts/release_orchestrator_transport_test.py', - 'scripts/release_orchestrator_workflow_runs.py', 'scripts/release_orchestrator_workflow_runs_test.py', 'scripts/release_qualification_test.py', 'scripts/release_contract_test.py', 'scripts/generate_release_manifest_test.py', 'tests/js/native_core_source.mjs', diff --git a/scripts/ci/verify_ci_reliability.mjs b/scripts/ci/verify_ci_reliability.mjs index 783b7cd..e7603af 100644 --- a/scripts/ci/verify_ci_reliability.mjs +++ b/scripts/ci/verify_ci_reliability.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // Static checks for the publication-safety invariants of the CI, candidate, -// qualification, publication and orchestration workflows: permissions, +// qualification and publication workflows: permissions, // immutability, environment gates, publication PAT handling, model and // toolchain pins, fail-closed guards, first-attempt-only runs, artifact // download by immutable ID, and that CI runs the contract tests. @@ -23,7 +23,6 @@ const WORKFLOWS = { candidate: '.github/workflows/bridge_candidate.yml', qualification: '.github/workflows/bridge_qualification.yml', publish: '.github/workflows/publish_assets.yml', - autoUpdate: '.github/workflows/auto_llama_cpp_update.yml', }; // --------------------------------------------------------------------------- @@ -808,8 +807,6 @@ export const TEST_COMMAND = "node --test 'tests/**/*_test.mjs'"; // Static imports, re-exports, bare imports and literal dynamic imports. const RELATIVE_IMPORT = /(?:\bfrom|\bimport)\s*\(?\s*(['"`])(\.\.?\/[^'"`$]+)\1/g; -// Every literal module specifier, relative or not. -const IMPORT_SPECIFIER = /(?:\bfrom|\bimport)\s*\(?\s*(['"`])([^'"`$]+)\1/g; function relativeImports(from, text) { return [...String(text).matchAll(RELATIVE_IMPORT)] @@ -851,17 +848,17 @@ export function checkJsContractTestsRegistered(packageJson, testFiles, errors) { } // R4: the publication PAT is the only secret, referenced only by the -// publication and orchestration workflows. Every workflow file is scanned, so a -// new one cannot escape. +// publication workflow. Every workflow file is scanned, so a new one cannot +// escape. function checkSecretReferences(workflows, errors) { const reference = new RegExp(`^${PUBLICATION_PAT_REFERENCE.source}`); for (const workflow of workflows) { - const allowed = workflow.path === WORKFLOWS.publish || workflow.path === WORKFLOWS.autoUpdate; + const allowed = workflow.path === WORKFLOWS.publish; for (const match of workflow.text.matchAll(new RegExp(SECRETS_CONTEXT.source, 'gi'))) { if (!allowed || !reference.test(workflow.text.slice(match.index))) { errors.push( `${workflow.path}:${lineNumberAt(workflow.text, match.index)} references the secrets context; only ` - + `${WORKFLOWS.publish} and ${WORKFLOWS.autoUpdate} may, and only as secrets.${PUBLICATION_PAT_NAME}`, + + `${WORKFLOWS.publish} may, and only as secrets.${PUBLICATION_PAT_NAME}`, ); } } @@ -1058,16 +1055,10 @@ function checkToolchainPins({ ci, candidate, publish }, files, errors) { ); } -// The release orchestrator: the Node CLI entry and the state-machine modules -// beside it, and the release contract and qualification CLIs the scan -// workflow runs. -export const ORCHESTRATOR_DIRECTORY = 'scripts/release/orchestrator'; -export const ORCHESTRATOR_ENTRY = `${ORCHESTRATOR_DIRECTORY}/cli.mjs`; -export const ORCHESTRATOR_COMMAND = `node ${ORCHESTRATOR_ENTRY}`; +// The release and build CLIs the candidate, qualification and publish +// workflows run, and the trusted-checkout forms publish-assets runs. export const RELEASE_CONTRACT_COMMAND = 'node scripts/release/contract.mjs'; export const RELEASE_QUALIFICATION_COMMAND = 'node scripts/release/qualification.mjs'; -// The other release and build CLIs the candidate, qualification and publish -// workflows run, and the trusted-checkout forms publish-assets runs. export const RELEASE_MANIFEST_MODULE = 'scripts/release/manifest.mjs'; export const RELEASE_MANIFEST_COMMAND = `node ${RELEASE_MANIFEST_MODULE}`; export const PUBLICATION_STATE_COMMAND = 'node scripts/release/publication_state.mjs'; @@ -1079,67 +1070,6 @@ export const POLICY_PUBLICATION_STATE_COMMAND = 'node publication-policy/scripts export const EXTRACT_ARTIFACT_COMMAND = `${RELEASE_QUALIFICATION_COMMAND} extract-artifact`; // qualify runs the candidate's own harness, from its checkout. export const QUALIFY_COMMAND = 'node candidate-source/scripts/release/qualification.mjs qualify'; -// The shared release modules the orchestrator imports. They have their own -// contracts, so their text is not part of the orchestrator source; any other -// relative import would hide code from the checks and is rejected. -export const SHARED_RELEASE_MODULES = Object.freeze([ - 'archive', 'cli', 'contract', 'json', 'manifest', 'publication_state', 'python_compat', 'qualification', -].map((name) => `scripts/release/${name}.mjs`)); - -/** - * Returns the orchestrator source as { path: text }: the entry, then every - * module it reaches through relative imports, by path. `files` maps each - * scripts/release/orchestrator/*.mjs path to its text. A module the entry - * does not reach, an import of a missing module, a relative import outside - * the orchestrator and SHARED_RELEASE_MODULES, or an import whose target is - * not a string literal is an error, so a check can neither silently widen nor - * narrow. - */ -export function orchestratorModules(files, errors) { - if (!has(files, ORCHESTRATOR_ENTRY)) { - errors.push(`${ORCHESTRATOR_ENTRY} is missing`); - return {}; - } - const reached = new Set([ORCHESTRATOR_ENTRY]); - const pending = [ORCHESTRATOR_ENTRY]; - while (pending.length > 0) { - const from = pending.pop(); - const text = String(files[from]); - if (/\bimport\s*\(\s*(?!['"`][^'"`$]*['"`]\s*\))/.test(text)) { - errors.push(`${from} has an import() whose target is not a string literal, so the orchestrator source cannot be resolved`); - } - // Only node: builtins and relative modules, never a package, a subpath - // import, an absolute path or URL, or CommonJS loading, which would all - // load code this walk never reads. - for (const [, , specifier] of text.matchAll(IMPORT_SPECIFIER)) { - if (!/^(?:node:|\.\.?\/)/.test(specifier)) { - errors.push(`${from} imports ${specifier}; the orchestrator may import only node: builtins and relative modules`); - } - } - if (/\bcreateRequire\b|(? !reached.has(relativePath)).sort(); - if (unreached.length > 0) { - errors.push(`${ORCHESTRATOR_ENTRY} must reach every ${ORCHESTRATOR_DIRECTORY}/*.mjs module through its imports; unreached: ${unreached.join(', ')}`); - } - const modules = [ORCHESTRATOR_ENTRY, ...[...reached].filter((relativePath) => relativePath !== ORCHESTRATOR_ENTRY).sort()]; - return Object.fromEntries(modules.map((relativePath) => [relativePath, files[relativePath]])); -} - const NODE_COMMAND = /(?:^|[\s;&|(`$])node\s/; const PACKAGE_COMMAND = /\b(?:npm|pnpm|yarn|npx|pip3?|corepack)\b/; @@ -1192,158 +1122,6 @@ export function checkNodeSetup({ ci, candidate, qualification, publish }, errors if (unlisted.length > 0) errors.push(`${publish.path} has no job ${unlisted.join(', ')}; update PRIVILEGED_PUBLISH_JOBS`); } -// Row 53: the dispatch booleans the orchestrator may never write as a literal -// 'true'. Each JavaScript spelling is matched: a property (`{ key: 'true' }`, -// `"key": "true"`), an assignment (`inputs.key = 'true'`, -// `inputs['key'] = 'true'`) and a Map entry or set (`['key', 'true']`, -// `.set('key', 'true')`), with any quote. -export const LIVE_PROOF_DISPATCH_BOOLEANS = ['assets_immutable_releases_enabled', 'publish_approved']; -// The one sanctioned literal: in the driver, the approval is asserted only on -// the statement right after the live publication-environment proof. Both -// statements must open their own lines, so a commented-out or conditional -// proof does not sanction the assignment. -export const SANCTIONED_APPROVAL_MODULE = 'scripts/release/orchestrator/driver.mjs'; -export const SANCTIONED_APPROVAL = /^[ \t]*requirePublicationEnvironment\(gateway\);[ \t]*\r?\n[ \t]*inputs\.publish_approved = 'true';/gm; - -export function literalDispatchBooleans(source, { sanctioned = false } = {}) { - const text = sanctioned ? String(source).replace(SANCTIONED_APPROVAL, '') : String(source); - const found = []; - for (const key of LIVE_PROOF_DISPATCH_BOOLEANS) { - // The key, bare or quoted, then the literal 'true' in any quote. - const quotedKey = String.raw`(?:${key}\b|(['"\x60])${key}\1)`; - const literalTrue = String.raw`(['"\x60])true\2`; - const spellings = [ - // A property: { key: 'true' } or "key": "true". - new RegExp(String.raw`(? Object.values(step.env).some(containsPat)); - check.require( - patSteps.length > 0 - && patSteps.every((step) => step.env[PUBLICATION_PAT_NAME] === '${{ secrets.WEBGPU_BRIDGE_ASSETS_PAT }}'), - `${autoUpdate.path} must bind the publication PAT only as ${PUBLICATION_PAT_NAME}: \${{ secrets.WEBGPU_BRIDGE_ASSETS_PAT }}`, - ); - for (const step of patSteps) { - const environment = autoUpdate.job(step.job).environment; - const environmentName = typeof environment === 'string' ? environment : mapping(environment).name; - const validated = autoUpdate.jobSteps(step.job).some((other) => other.index < step.index - && other.env.GH_TOKEN === '${{ github.token }}' - && environmentValidationMissing(shellCommands(other.run), RELEASE_CONTRACT_COMMAND).length === 0); - check.require( - environmentName === 'bridge-assets-publication' && validated, - `${autoUpdate.path} job ${step.job} may use the PAT only inside the bridge-assets-publication environment, after a github.token step validates that environment policy with ${RELEASE_CONTRACT_COMMAND} validate-environment`, - ); - // The credential reaches only the orchestrator entry, which alone dispatches. - check.require( - shellCommands(step.run).includes(`${ORCHESTRATOR_COMMAND} orchestrate-backlog`), - `${autoUpdate.path} step ${step.job}/${step.name || step.index} may hand the PAT only to ${ORCHESTRATOR_COMMAND} orchestrate-backlog`, - ); - } - // The release modules are zero-dependency: every job that runs one sets up - // Node.js 24 before its first node command, with no package cache, and the - // workflow never installs packages, so no dependency code runs beside the - // publication PAT. - for (const name of Object.keys(autoUpdate.jobs)) errors.push(...nodeSetupErrors(autoUpdate, name, { privileged: true })); - check.require( - !PACKAGE_COMMAND.test(autoUpdate.runText), - `${autoUpdate.path} must never install or run packages (npm, npx, pnpm, yarn, pip, corepack); the release modules are zero-dependency`, - ); - // Rows 50-51: every job, and so every environment job, rejects a non-owner - // manual caller and an unsuccessful, non-default-branch or non-owner - // workflow_run before the environment exposes the credential. - const ownerManualGate = "github.event_name == 'workflow_dispatch' && github.actor == github.repository_owner && github.triggering_actor == github.repository_owner"; - const ownerWorkflowRunGate = "github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && " - + 'github.event.workflow_run.head_branch == github.event.repository.default_branch && ' - + 'github.event.workflow_run.actor.login == github.repository_owner && ' - + 'github.event.workflow_run.triggering_actor.login == github.repository_owner'; - const jobNames = Object.keys(autoUpdate.jobs); - check.require( - jobNames.length > 0 && jobNames.every((name) => { - const condition = normalizeSpace(autoUpdate.job(name).if); - return condition.includes(ownerManualGate) && condition.includes(ownerWorkflowRunGate); - }), - `${autoUpdate.path}: every job must gate manual runs and workflow_run continuations on the repository owner before the publication environment can expose its credential`, - ); - const workflowRun = mapping(mapping(autoUpdate.data.on).workflow_run); - check.require( - sameList(sorted(Array.isArray(workflowRun.workflows) ? workflowRun.workflows : []), - ['Build Exact Bridge Candidate', 'Publish Exact Qualified Bridge Assets', 'Qualify Exact Bridge Candidate']) - && deepEqual(workflowRun.types, ['completed']), - `${autoUpdate.path} must listen only to completed runs of the three exact stage workflows`, - ); - const proof = autoUpdate.steps.find((step) => step.name === 'Prove the exact workflow continuation before environment use'); - check.require( - proof !== undefined - && autoUpdate.job(proof.job).environment === undefined - && jobNames.every((name) => autoUpdate.job(name).environment === undefined || autoUpdate.transitiveNeeds(name).has(proof.job)) - && shellCommands(proof.run).includes(`${RELEASE_QUALIFICATION_COMMAND} verify-run`) - && proof.run.includes('--run-attempt 1') - && /\.github\/workflows\/bridge_candidate\.yml\)\s+artifact_name=exact-webgpu-bridge-dist\b/.test(proof.run) - && /\.github\/workflows\/bridge_qualification\.yml\)\s+artifact_name=qualification-attestation\b/.test(proof.run) - && /\.github\/workflows\/publish_assets\.yml\)\s+artifact_name=bridge-qualification-outcome\b/.test(proof.run), - `${autoUpdate.path} must prove a continuation's first attempt and exact stage artifact with ${RELEASE_QUALIFICATION_COMMAND} verify-run, in a job every environment job needs`, - ); - // Row 53: the governance and approval booleans come only from live proofs. - check.require( - has(orchestratorSources, ORCHESTRATOR_ENTRY), - `the orchestrator source must start at ${ORCHESTRATOR_ENTRY}`, - ); - for (const [relativePath, source] of Object.entries(orchestratorSources)) { - const literals = literalDispatchBooleans(source, { sanctioned: relativePath === SANCTIONED_APPROVAL_MODULE }); - check.require( - literals.length === 0, - `${relativePath} must derive the governance and approval booleans only from live proofs, never a literal; found: ${quoteAll(literals)}`, - ); - } - check.require( - (String(orchestratorSources[SANCTIONED_APPROVAL_MODULE] ?? '').match(SANCTIONED_APPROVAL) ?? []).length === 1, - `${SANCTIONED_APPROVAL_MODULE} must assert publish_approved exactly once, on the statement right after requirePublicationEnvironment(gateway)`, - ); -} - -export { checkOrchestration }; - function checkPublication({ publish, candidate }, errors) { const check = checker(errors); const { text, runText } = publish; @@ -1470,17 +1248,21 @@ function checkPublication({ publish, candidate }, errors) { && step.env.NATIVE_TAG_COMMIT === '${{ steps.native_tag.outputs.native_tag_commit }}'), `${publish.path} must resolve the upstream and native tag commits itself and check the manifest against the resolved native tag commit (steps.native_tag)`, ); - const nativeRequest = runText.indexOf(`${RELEASE_CONTRACT_COMMAND} validate-native-request`); - const upstreamLookup = runText.indexOf('git ls-remote https://github.com/ggml-org/llama.cpp.git'); - const nativeLookup = runText.indexOf('git ls-remote "https://github.com/${NATIVE_REPO}.git"'); - const requestSpan = runText.slice(nativeRequest, nativeLookup); - check.require( - nativeRequest >= 0 && nativeRequest < upstreamLookup && upstreamLookup < nativeLookup - && requestSpan.includes('--native-release-tag "${NATIVE_RELEASE_TAG}"') - && requestSpan.includes('--upstream-commit "${UPSTREAM_COMMIT}"') - && requestSpan.includes('--manifest-sha256 "${NATIVE_MANIFEST_SHA256}"'), - `${publish.path} must validate native tag, upstream commit, and manifest SHA-256 inputs before the first native network request`, - ); + // validate-native-request also rejects a development native release, so + // both workflows that take native inputs must run it. + for (const workflow of [publish, candidate]) { + const nativeRequest = workflow.runText.indexOf(`${RELEASE_CONTRACT_COMMAND} validate-native-request`); + const upstreamLookup = workflow.runText.indexOf('git ls-remote https://github.com/ggml-org/llama.cpp.git'); + const nativeLookup = workflow.runText.indexOf('git ls-remote "https://github.com/${NATIVE_REPO}.git"'); + const requestSpan = workflow.runText.slice(nativeRequest, nativeLookup); + check.require( + nativeRequest >= 0 && nativeRequest < upstreamLookup && upstreamLookup < nativeLookup + && requestSpan.includes('--native-release-tag "${NATIVE_RELEASE_TAG}"') + && requestSpan.includes('--upstream-commit "${UPSTREAM_COMMIT}"') + && requestSpan.includes('--manifest-sha256 "${NATIVE_MANIFEST_SHA256}"'), + `${workflow.path} must validate native tag, upstream commit, and manifest SHA-256 inputs before the first native network request`, + ); + } // Row 76: preflight and recovery resolve existing asset tags strictly. for (const name of ['Verify source ancestry and classify exact remote state', 'Re-fetch and finish or safely classify partial publication']) { const step = publish.step('publish-assets', name); @@ -1857,11 +1639,6 @@ export function collectErrors(overrides = {}) { 'package.json', 'llama_cpp.version', 'emsdk.version', 'README.md', 'AGENTS.md', 'CONTRIBUTING.md', EMSCRIPTEN_VERIFIER, 'scripts/build/build_bridge.sh', RELEASE_MANIFEST_MODULE, ].map((relativePath) => [relativePath, read(relativePath)])); - const orchestratorSources = orchestratorModules(Object.fromEntries( - listRequired(ORCHESTRATOR_DIRECTORY, /\.mjs$/, errors) - .map((name) => `${ORCHESTRATOR_DIRECTORY}/${name}`) - .map((relativePath) => [relativePath, read(relativePath)]), - ), errors); const known = new Set(Object.values(WORKFLOWS)); const allWorkflows = [ @@ -1886,7 +1663,6 @@ export function collectErrors(overrides = {}) { checkCiRunsContracts(workflows, testFiles, errors); checkNodeSetup(workflows, errors); checkToolchainPins(workflows, files, errors); - checkOrchestration(workflows.autoUpdate, orchestratorSources, errors); checkPublication(workflows, errors); checkCandidateAndQualification(workflows, errors); checkModelPins(files, workflows, errors); diff --git a/scripts/release/contract.mjs b/scripts/release/contract.mjs index a5d1414..833d8af 100644 --- a/scripts/release/contract.mjs +++ b/scripts/release/contract.mjs @@ -360,8 +360,15 @@ export function validateNativeIdentity(nativeReleaseTag, rebuild, upstreamTag) { } // Validate native release inputs before using them in network requests. +// The candidate and publish requests: bridge assets are built only from a +// stable native release, never a development (bNNNN) one. export function validateNativeRequest(nativeReleaseTag, upstreamTag, upstreamCommit, manifestSha256) { const nativeRelease = parseReleaseTag(nativeReleaseTag); + if (nativeRelease.channel !== Channel.STABLE) { + throw new ContractError( + `native release tag ${pyRepr(nativeReleaseTag)} is not a stable release; use vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-N`, + ); + } validateNativeIdentity(nativeReleaseTag, nativeRelease.rebuild, upstreamTag); requireCommit(upstreamCommit, 'upstream_commit'); requireSha256(manifestSha256, 'native_manifest_sha256'); @@ -682,34 +689,6 @@ export function validateNativeRelease( return identity; } -// Select the highest supported stable-channel tag without GitHub latest -// semantics. On a tie the first listed release wins, as max() keeps it. -export function selectStableNativeRelease(releases) { - let selected = null; - let selectedKey = null; - for (const release of releases) { - if (!isDict(release) || pyGet(release, 'draft') !== false) continue; - const tag = pyGet(release, 'tag_name'); - if (typeof tag !== 'string') continue; - let version; - try { - version = parseReleaseTag(tag, { allowLegacy: true }); - } catch (error) { - if (error instanceof ContractError) continue; - throw error; - } - if (version.channel === Channel.STABLE && pyGet(release, 'prerelease') === version.githubPrerelease) { - const key = [...version.versionParts, version.rebuild]; - if (selected === null || compareIntTuples(key, selectedKey) > 0) { - selected = version; - selectedKey = key; - } - } - } - if (selected === null) throw new ContractError('no supported non-draft stable native release exists'); - return selected.tag; -} - // Require the exact fail-closed solo-maintainer publication policy. export function validatePublicationEnvironment(environment, branchPolicies) { if (pyGet(environment, 'name') !== 'bridge-assets-publication') { @@ -1137,15 +1116,6 @@ export const COMMANDS = Object.freeze({ { flag: '--previous-manifest', required: false, type: 'path' }, ], }, - 'validate-native': { - options: [ - required('--manifest', 'path'), - required('--manifest-sha256'), - required('--native-release-tag'), - required('--upstream-tag'), - required('--upstream-commit'), - ], - }, 'validate-native-release': { options: [ required('--manifest', 'path'), @@ -1167,9 +1137,6 @@ export const COMMANDS = Object.freeze({ ], }, 'resolve-tag-commit': { options: [required('--ls-remote', 'path'), required('--tag')] }, - 'compare-upstream': { positionals: ['current', 'target'] }, - 'scan-native': { options: [required('--manifest', 'path'), required('--native-release-tag')] }, - 'select-stable-native-release': { options: [required('--releases-json', 'path')] }, 'validate-immutable-release-governance': { options: [required('--governance-json', 'path'), required('--repository')], }, @@ -1228,9 +1195,6 @@ function runCommand(command, args, write) { }); } write(dumps(result)); - } else if (command === 'validate-native') { - const identity = validateNativeFile(args.manifest, args.manifestSha256, args.nativeReleaseTag, args.upstreamTag, args.upstreamCommit); - write(dumps(identity.toDict())); } else if (command === 'validate-native-release') { const releasePayload = pyJsonLoads(pyReadText(args.releaseJson)); if (!isDict(releasePayload)) throw new ContractError('native GitHub release root must be an object'); @@ -1250,15 +1214,6 @@ function runCommand(command, args, write) { write(dumps({ native_release_tag: nativeRelease.tag, upstream_tag: args.upstreamTag })); } else if (command === 'resolve-tag-commit') { write(`${resolveTagCommit(pyReadText(args.lsRemote), args.tag)}\n`); - } else if (command === 'compare-upstream') { - write(`${compareUpstream(args.current, args.target)}\n`); - } else if (command === 'scan-native') { - const payload = pyJsonLoads(pyReadText(args.manifest)); - write(dumps(resolveNativeManifest(payload, args.nativeReleaseTag).toDict())); - } else if (command === 'select-stable-native-release') { - const releases = pyJsonLoads(pyReadText(args.releasesJson)); - if (!Array.isArray(releases)) throw new ContractError('native release listing must be a JSON array'); - write(`${selectStableNativeRelease(releases)}\n`); } else if (command === 'validate-immutable-release-governance') { const governancePayload = strictJsonLoads(pyReadText(args.governanceJson), 'immutable-release governance response'); write(dumps(validateImmutableReleaseGovernance(governancePayload, args.repository))); diff --git a/scripts/release/json.mjs b/scripts/release/json.mjs index cda72cd..d01e602 100644 --- a/scripts/release/json.mjs +++ b/scripts/release/json.mjs @@ -1186,25 +1186,6 @@ export function pyStrptimeUtc(value) { return { year, month, day, hour, minute, second }; } -// datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") as pyStrptimeUtc parses it, -// raising the ValueError CPython 3.12 raises where pyStrptimeUtc returns null: -// no match, unconverted data, then the datetime constructor's range checks in -// its order (year, day, second). -export function pyStrptimeUtcOrRaise(value) { - const match = UTC_FORMAT.exec(value); - if (!match) { - throw new PyException('ValueError', `time data ${pyRepr(value)} does not match format '%Y-%m-%dT%H:%M:%SZ'`); - } - if (match[0].length !== value.length) { - throw new PyException('ValueError', `unconverted data remains: ${value.slice(match[0].length)}`); - } - const [year, month, day, hour, minute, second] = match.slice(1).map(decimalText); - if (year < 1) throw new PyException('ValueError', `year ${year} is out of range`); - if (day > daysInMonth(year, month)) throw new PyException('ValueError', 'day is out of range for month'); - if (second > 59) throw new PyException('ValueError', 'second must be in 0..59'); - return { year, month, day, hour, minute, second }; -} - // .strftime("%Y-%m-%dT%H:%M:%SZ") with glibc's unpadded %Y, as on the Linux // runners (macOS pads a year below 1000 to four digits). export function pyStrftimeUtc({ year, month, day, hour, minute, second }) { diff --git a/scripts/release/orchestrator/asset_releases.mjs b/scripts/release/orchestrator/asset_releases.mjs deleted file mode 100644 index 781b8eb..0000000 --- a/scripts/release/orchestrator/asset_releases.mjs +++ /dev/null @@ -1,487 +0,0 @@ -// Assets-repository release discovery and proof for the orchestrator, the Node -// port of scripts/release_orchestrator_asset_releases.py. -// -// Resolves tag commits, lists releases and tag refs, finds the release a -// correlation or native alignment points to, compares publication bytes, -// bounds run recovery, and re-proves a published release through independent -// readbacks, downloaded bytes, and its signed release attestation. -// -// A release listing is JSON: plain objects (or Maps) read with pyGet. A tuple -// result is an array ([nativeTag, assetTag], [release, correlationId]) and -// None is null. - -import { createHash } from 'node:crypto'; -import fs from 'node:fs'; - -import { - ASSETS_REPOSITORY, Channel, ContractError, NATIVE_REPOSITORY, parseReleaseTag, requireCorrelationId, requireRepository, - requireSha256, validateReleaseAttestation, validateReleaseImmutability, -} from '../contract.mjs'; -import { - compareCodePoints, isDict, isPyInt, pyEquals, pyGet, pyJoinPath, pyKeys, pyOSError, pyPath, pyQuote, pyRepr, pyStr, -} from '../json.mjs'; -import { ARTIFACTS } from '../manifest.mjs'; -import { PUBLICATION_FILES } from '../publication_state.mjs'; -import { pyCompareIntTuples, pyFullmatch } from '../python_compat.mjs'; -import { loadCandidate, loadPublishedCandidate } from '../qualification.mjs'; -import { - COMMIT_RE, LEGACY_MANUAL_QUALIFICATION_BRIDGE_COMMIT, LEGACY_MANUAL_QUALIFICATION_RELEASE_TAG, PublishedRelease, - UTC_TIMESTAMP_RE, publishedManifestCompatibility, requirePositiveInt, requireStr, -} from './model.mjs'; -import { nativeReleaseOrder } from './native.mjs'; -import { validateCandidateManifest } from './stage_proofs.mjs'; - -// isinstance(value, Mapping) for a JSON value. -function isMapping(value) { - return isDict(value) || value instanceof Map; -} - -// Parse a release tag, or null where Python catches the ContractError. -function tryParseReleaseTag(tag, options) { - try { - return parseReleaseTag(tag, options); - } catch (error) { - if (error instanceof ContractError) return null; - throw error; - } -} - -// re.findall(r"(?m)^([^`\r\n]+)`$", text) for a pattern that -// cannot cross a line: Python's multiline ^ and $ match only at "\n" (JS's -// also match at "\r", U+2028 and U+2029), so each "\n"-separated line is -// matched on its own. -function findLineMatches(lineRe, text) { - const found = []; - for (const line of text.split('\n')) { - const match = lineRe.exec(line); - if (match !== null) found.push(match[1]); - } - return found; -} - -// Anchored per line; see findLineMatches. -const NATIVE_ALIGNMENT_LINE_RE = new RegExp(`^Native: \`${NATIVE_REPOSITORY.replace(/[\\^$.*+?()[\]{}|/]/gu, '\\$&')}@([^\`\\r\\n]+)\`\\r?$`, 'u'); -const CORRELATION_LINE_RE = /^Orchestrator correlation: `([^`\r\n]+)`$/u; - -// _pages: normalize `gh api --paginate --slurp` output to a page list. -export function pages(payload, label) { - if (Array.isArray(payload)) return payload; - if (isMapping(payload)) return [payload]; - throw new ContractError(`${label} response must be a JSON array or object`); -} - -// Resolve a lightweight or annotated tag to its immutable commit. -export function resolveRepositoryTagCommit(gateway, { repository, releaseTag }) { - requireRepository(repository, 'repository'); - parseReleaseTag(releaseTag); - const encodedTag = pyQuote(releaseTag, ''); - const payload = gateway.apiJson(`repos/${repository}/git/ref/tags/${encodedTag}`); - if (!isMapping(payload) || !pyEquals(pyGet(payload, 'ref'), `refs/tags/${releaseTag}`)) { - throw new ContractError('assets tag reference identity is missing or incorrect'); - } - let objectPayload = pyGet(payload, 'object'); - const seen = new Set(); - for (let step = 0; step < 8; step += 1) { - if (!isMapping(objectPayload)) throw new ContractError('assets tag reference object is malformed'); - const objectType = pyGet(objectPayload, 'type'); - const sha = pyGet(objectPayload, 'sha'); - if (typeof sha !== 'string' || COMMIT_RE.exec(sha) === null) { - throw new ContractError('assets tag reference object has no full commit SHA'); - } - if (seen.has(sha)) throw new ContractError('assets annotated tag chain contains a cycle'); - seen.add(sha); - if (pyEquals(objectType, 'commit')) return sha; - if (!pyEquals(objectType, 'tag')) { - throw new ContractError(`assets tag reference points to unsupported object type ${pyRepr(objectType)}`); - } - const annotated = gateway.apiJson(`repos/${repository}/git/tags/${sha}`); - if (!isMapping(annotated) || !pyEquals(pyGet(annotated, 'sha'), sha)) { - throw new ContractError('assets annotated tag identity is malformed'); - } - objectPayload = pyGet(annotated, 'object'); - } - throw new ContractError('assets annotated tag chain exceeds the validation bound'); -} - -export function fetchAssetReleases(gateway) { - const payload = gateway.apiJson(`repos/${ASSETS_REPOSITORY}/releases?per_page=100`, { paginate: true }); - const releases = []; - const seenTags = new Set(); - for (const page of pages(payload, 'asset releases')) { - for (const release of Array.isArray(page) ? page : [page]) { - if (!isMapping(release)) throw new ContractError('asset release record must be a JSON object'); - const tag = requireStr(pyGet(release, 'tag_name'), 'asset release tag_name'); - if (seenTags.has(tag)) throw new ContractError(`asset repository lists duplicate release ${pyRepr(tag)}`); - seenTags.add(tag); - releases.push(release); - } - } - return releases; -} - -// Every existing assets tag ref, as a Set, so output selection cannot collide. -export function fetchAssetTagNames(gateway) { - const payload = gateway.apiJson(`repos/${ASSETS_REPOSITORY}/git/matching-refs/tags?per_page=100`, { paginate: true }); - const tags = new Set(); - for (const page of pages(payload, 'asset tag refs')) { - for (const record of Array.isArray(page) ? page : [page]) { - if (!isMapping(record)) throw new ContractError('asset tag ref record must be a JSON object'); - const ref = pyGet(record, 'ref'); - if (typeof ref !== 'string' || !ref.startsWith('refs/tags/')) throw new ContractError('asset tag ref has an invalid ref name'); - const tag = ref.slice('refs/tags/'.length); - if (!tag || tags.has(tag)) throw new ContractError(`asset repository lists duplicate tag ref ${pyRepr(tag)}`); - const objectPayload = pyGet(record, 'object'); - if (!isMapping(objectPayload)) throw new ContractError(`asset tag ref ${pyRepr(tag)} has no object`); - const objectType = pyGet(objectPayload, 'type'); - if (!pyEquals(objectType, 'commit') && !pyEquals(objectType, 'tag')) { - throw new ContractError(`asset tag ref ${pyRepr(tag)} has invalid object type`); - } - const sha = pyGet(objectPayload, 'sha'); - if (typeof sha !== 'string' || COMMIT_RE.exec(sha) === null) { - throw new ContractError(`asset tag ref ${pyRepr(tag)} has invalid object SHA`); - } - tags.add(tag); - } - } - return tags; -} - -// _correlation_marker. -export function correlationMarker(correlationId) { - return `Orchestrator correlation: \`${correlationId}\``; -} - -// (*version.version_parts, version.rebuild). -function releaseOrder(version) { - return [...version.versionParts, version.rebuild]; -} - -// [nativeTag, assetTag] for the newest stable native release named by a -// non-draft stable asset release's `Native:` marker, else null. -// -// Raises ContractError if one release names several native releases or a -// malformed tag. -export function latestPublishedNativeAlignment(releases) { - let latest = null; - for (const release of releases) { - const assetTag = pyGet(release, 'tag_name'); - const body = pyGet(release, 'body'); - if (typeof assetTag !== 'string' || typeof body !== 'string') continue; - const assetVersion = tryParseReleaseTag(assetTag, { allowLegacy: true }); - if (assetVersion === null) continue; - if (assetVersion.channel !== Channel.STABLE || pyGet(release, 'draft') !== false) continue; - const claims = new Set(findLineMatches(NATIVE_ALIGNMENT_LINE_RE, body)); - if (claims.size === 0) continue; - if (claims.size !== 1) throw new ContractError(`asset release ${pyRepr(assetTag)} records ${claims.size} native alignments`); - const [nativeTag] = claims; - let nativeVersion; - try { - nativeVersion = parseReleaseTag(nativeTag); - } catch (error) { - if (error instanceof ContractError) { - throw new ContractError(`asset release ${pyRepr(assetTag)} records a malformed native alignment: ${error.message}`, { cause: error }); - } - throw error; - } - if (nativeVersion.channel !== Channel.STABLE) continue; - const order = nativeReleaseOrder(nativeTag); - if (latest === null || pyCompareIntTuples(order, latest[0]) > 0) latest = [order, nativeTag, assetTag]; - } - return latest === null ? null : latest.slice(1); -} - -// The non-draft stable asset release with the highest output tag, else null. -// Publication orders every new output tag after it, so a candidate bound to -// this tag or a lower one can never publish. -export function newestPublishedStableRelease(releases) { - let newest = null; - for (const release of releases) { - const tag = pyGet(release, 'tag_name'); - if (typeof tag !== 'string') continue; - const version = tryParseReleaseTag(tag, { allowLegacy: true }); - if (version === null || version.channel !== Channel.STABLE || pyGet(release, 'draft') !== false) continue; - const order = releaseOrder(version); - if (newest === null || pyCompareIntTuples(order, newest[0]) > 0) newest = [order, release]; - } - return newest === null ? null : newest[1]; -} - -// The one valid correlation claims of a release body, as require_correlation_id -// accepts them. -function validCorrelationClaims(body) { - const claims = []; - for (const claim of findLineMatches(CORRELATION_LINE_RE, body)) { - try { - claims.push(requireCorrelationId(claim)); - } catch (error) { - if (!(error instanceof ContractError)) throw error; - } - } - return claims; -} - -export function findCorrelatedRelease(releases, correlationId, provenance) { - const marker = correlationMarker(correlationId); - // The manifest digest is independently selected from the native release and - // is also written to deterministic release notes. If only the correlation - // line is damaged, still classify the release as relevant and let the full - // immutable readback reject it instead of dispatching duplicate provenance. - const nativeManifestMarker = `Native manifest SHA-256: \`${provenance.nativeManifestSha256}\``; - const nativeReleaseMarker = `Native: \`${provenance.nativeRepo}@${provenance.nativeReleaseTag}\``; - const matches = []; - for (const release of releases) { - const body = pyGet(release, 'body'); - if (typeof body !== 'string') continue; - if (body.includes(marker)) { - matches.push(release); - continue; - } - if (!body.includes(nativeManifestMarker) || !body.includes(nativeReleaseMarker)) continue; - // The same native release may intentionally have multiple bridge - // publications after governed runtime/build changes. A different - // well-formed correlation is another pipeline, not damaged state. - if (validCorrelationClaims(body).length > 0) continue; - // With no valid correlation marker, the native identity is still close - // enough to block a duplicate until immutable readback diagnoses it. - matches.push(release); - } - if (matches.length > 1) { - throw new ContractError( - `${matches.length} asset releases claim correlation ${pyRepr(correlationId)}: ` - + matches.map((release) => pyStr(pyGet(release, 'tag_name'))).join(', '), - ); - } - return matches.length > 0 ? matches[0] : null; -} - -// [release, correlationId] for the newest non-draft stable asset release whose -// notes record this provenance's exact native release and native manifest -// digest, with the one correlation it claims, else null. -// -// A release with the same native markers but not exactly one well-formed -// correlation marker is skipped. The pre-automation v0.1.40 manifest follows a -// different contract and is only comparable by its own provenance. -export function latestAlignedRelease(releases, provenance) { - const nativeReleaseMarker = `Native: \`${provenance.nativeRepo}@${provenance.nativeReleaseTag}\``; - const nativeManifestMarker = `Native manifest SHA-256: \`${provenance.nativeManifestSha256}\``; - let latest = null; - for (const release of releases) { - const tag = pyGet(release, 'tag_name'); - const body = pyGet(release, 'body'); - if (typeof tag !== 'string' || typeof body !== 'string') continue; - const version = tryParseReleaseTag(tag, { allowLegacy: true }); - if (version === null) continue; - if (version.channel !== Channel.STABLE || pyGet(release, 'draft') !== false) continue; - if (!body.includes(nativeReleaseMarker) || !body.includes(nativeManifestMarker)) continue; - if (tag === LEGACY_MANUAL_QUALIFICATION_RELEASE_TAG && publishedManifestCompatibility({ tag, provenance }) === null) continue; - const claims = validCorrelationClaims(body); - if (claims.length !== 1) continue; - const order = releaseOrder(version); - if (latest === null || pyCompareIntTuples(order, latest[0]) > 0) latest = [order, release, claims[0]]; - } - return latest === null ? null : latest.slice(1); -} - -// True when every publication file except manifest.json has the same SHA-256 -// in both complete inventories (dicts or Maps of name to digest). -// -// manifest.json embeds the candidate run ID/URL, output tag, correlation and -// bridge commit, so it differs between any two builds; sha256sums.txt lists -// only the artifact digests and is compared. -export function publicationBytesIdentical(candidateDigests, publishedDigests) { - const complete = (digests) => { - const names = new Set(pyKeys(digests)); - return names.size === PUBLICATION_FILES.length && PUBLICATION_FILES.every((name) => names.has(name)); - }; - if (!complete(candidateDigests) || !complete(publishedDigests)) { - throw new ContractError('publication digest inventories must be complete'); - } - return PUBLICATION_FILES - .filter((name) => name !== 'manifest.json') - .every((name) => pyEquals(pyGet(candidateDigests, name), pyGet(publishedDigests, name))); -} - -// Bound run recovery to state that can still claim the next output tag. -// -// Candidate claims made before the most recently published stable assets tag -// cannot collide with a later monotonic output version. Taking the earlier of -// that publication and the native release still includes a prior unfinished -// pipeline when a newer native release appears, without coupling recovery to -// the repository's unbounded lifetime run count. -export function workflowHistorySince(releases, provenance) { - const stablePublications = []; - for (const release of releases) { - const tag = pyGet(release, 'tag_name'); - if (typeof tag !== 'string') continue; - const parsed = tryParseReleaseTag(tag, { allowLegacy: true }); - if (parsed === null) continue; - if (parsed.channel !== Channel.STABLE || pyGet(release, 'draft') === true) continue; - if (pyGet(release, 'draft') !== false) throw new ContractError(`stable asset release ${pyRepr(tag)} has invalid draft state`); - if (pyGet(release, 'prerelease') !== parsed.githubPrerelease) { - throw new ContractError(`stable asset release ${pyRepr(tag)} has invalid prerelease state`); - } - const publishedAt = pyGet(release, 'published_at'); - if (typeof publishedAt !== 'string' || pyFullmatch(UTC_TIMESTAMP_RE, publishedAt) === null) { - throw new ContractError(`stable asset release ${pyRepr(tag)} has no canonical published_at`); - } - stablePublications.push(publishedAt); - } - if (stablePublications.length === 0) return provenance.nativeReleasePublishedAt; - const newest = stablePublications.reduce((left, right) => (compareCodePoints(right, left) > 0 ? right : left)); - return compareCodePoints(newest, provenance.nativeReleasePublishedAt) < 0 ? newest : provenance.nativeReleasePublishedAt; -} - -// _release_assets: a Map of name to asset record, in listing order. -export function releaseAssets(release) { - const raw = pyGet(release, 'assets'); - if (!Array.isArray(raw)) throw new ContractError('GitHub Release is missing its asset inventory'); - const assets = new Map(); - for (const item of raw) { - if (!isMapping(item)) throw new ContractError('GitHub Release contains an invalid asset record'); - const name = requireStr(pyGet(item, 'name'), 'GitHub Release asset name'); - if (assets.has(name)) throw new ContractError(`GitHub Release has duplicate asset ${pyRepr(name)}`); - if (!pyEquals(pyGet(item, 'state'), 'uploaded')) throw new ContractError(`GitHub Release asset ${pyRepr(name)} is not uploaded`); - const digest = pyGet(item, 'digest'); - if (typeof digest !== 'string' || !digest.startsWith('sha256:')) { - throw new ContractError(`GitHub Release asset ${pyRepr(name)} has no SHA-256 digest`); - } - requireSha256(digest.slice('sha256:'.length), `digest for ${name}`); - const size = pyGet(item, 'size'); - if (!isPyInt(size) || BigInt(size) < 0n) throw new ContractError(`GitHub Release asset ${pyRepr(name)} has an invalid size`); - requirePositiveInt(pyGet(item, 'id'), `GitHub Release asset ${pyRepr(name)} id`); - assets.set(name, item); - } - const expected = new Set(PUBLICATION_FILES); - const unexpected = [...assets.keys()].filter((name) => !expected.has(name)).sort(compareCodePoints); - const missing = PUBLICATION_FILES.filter((name) => !assets.has(name)).sort(compareCodePoints); - if (unexpected.length > 0 || missing.length > 0) { - throw new ContractError( - 'GitHub Release asset inventory is not the exact publication set ' - + `(unexpected: ${pyRepr(unexpected)}, missing: ${pyRepr(missing)})`, - ); - } - return assets; -} - -// _candidate_fingerprint_marker. -export function candidateFingerprintMarker(fingerprint) { - return `Candidate fingerprint: \`${fingerprint}\``; -} - -// shutil.rmtree(directory) if it exists, then Path.mkdir(parents=True). -function freshDirectory(directory) { - try { - if (fs.existsSync(directory)) fs.rmSync(directory, { recursive: true }); - fs.mkdirSync(directory, { recursive: true }); - } catch (error) { - throw pyOSError(error, directory); - } -} - -// Prove an existing release really is this provenance, published immutably. -// -// The release listing is discovery only. The exact tag commit, independent -// release reads by tag and ID, downloaded artifact bytes, and GitHub's signed -// release attestation are all re-proven with the same validators used after -// publication before this path may return a noop. `workspace` is a path string. -export function verifyPublishedRelease(gateway, { - release, provenance, correlationId, workspace, -}) { - const tag = requireStr(pyGet(release, 'tag_name'), 'release tag_name'); - parseReleaseTag(tag); - const releaseId = requirePositiveInt(pyGet(release, 'id'), 'release id'); - const tagCommit = resolveRepositoryTagCommit(gateway, { repository: ASSETS_REPOSITORY, releaseTag: tag }); - const encodedTag = pyQuote(tag, ''); - const releaseByTag = gateway.apiJson(`repos/${ASSETS_REPOSITORY}/releases/tags/${encodedTag}`); - const releaseById = gateway.apiJson(`repos/${ASSETS_REPOSITORY}/releases/${releaseId}`); - if (!isMapping(releaseByTag) || !isMapping(releaseById)) throw new ContractError('release readbacks must be JSON objects'); - const resolvedId = validateReleaseImmutability(releaseByTag, { releaseTag: tag, tagCommit, releaseId }); - if (!pyEquals(validateReleaseImmutability(releaseById, { releaseTag: tag, tagCommit, releaseId: resolvedId }), resolvedId)) { - throw new ContractError('release readbacks by tag and ID disagree on release id'); - } - for (const [label, current] of [['tag', releaseByTag], ['id', releaseById]]) { - if (!pyEquals(pyGet(current, 'name'), tag)) throw new ContractError(`release readback by ${label} is not named ${pyRepr(tag)}`); - const body = pyGet(current, 'body'); - if (typeof body !== 'string' || !body.includes(correlationMarker(correlationId))) { - throw new ContractError(`release readback by ${label} does not record correlation ${pyRepr(correlationId)}`); - } - } - if (!pyEquals(pyGet(releaseByTag, 'published_at'), pyGet(releaseById, 'published_at'))) { - throw new ContractError('release readbacks disagree on published_at'); - } - if (!pyEquals(pyGet(releaseByTag, 'body'), pyGet(releaseById, 'body'))) { - throw new ContractError('release readbacks disagree on release body'); - } - const publishedAt = requireStr(pyGet(releaseByTag, 'published_at'), 'release published_at'); - const body = requireStr(pyGet(releaseByTag, 'body'), 'release body'); - - const assets = releaseAssets(releaseByTag); - const assetsById = releaseAssets(releaseById); - for (const name of PUBLICATION_FILES) { - const fields = ['id', 'name', 'state', 'size', 'digest']; - if (fields.some((field) => !pyEquals(pyGet(assets.get(name), field), pyGet(assetsById.get(name), field)))) { - throw new ContractError(`release readbacks disagree on asset ${pyRepr(name)} identity`); - } - } - const directory = pyJoinPath(pyPath(String(workspace)), `published-${releaseId}`); - freshDirectory(directory); - const expectedAssetDigests = new Map(); - for (const [name, asset] of assets) { - const data = gateway.downloadBytes(`repos/${ASSETS_REPOSITORY}/releases/assets/${pyGet(asset, 'id')}`, { - accept: 'application/octet-stream', - }); - const actual = createHash('sha256').update(data).digest('hex'); - if (!pyEquals(`sha256:${actual}`, pyGet(asset, 'digest'))) { - throw new ContractError(`release asset ${pyRepr(name)} does not match its GitHub digest`); - } - if (!pyEquals(data.length, pyGet(asset, 'size'))) throw new ContractError(`release asset ${pyRepr(name)} does not match its GitHub size`); - expectedAssetDigests.set(name, actual); - const target = pyJoinPath(directory, name); - try { - fs.writeFileSync(target, data); - } catch (error) { - throw pyOSError(error, target); - } - } - - const compatibility = publishedManifestCompatibility({ tag, provenance }); - const [manifest, fingerprint] = compatibility === null - ? loadCandidate(directory) - : loadPublishedCandidate(directory, { - expectedQualificationGates: compatibility[0], - expectedUnprovenCapabilities: compatibility[1], - }); - if (!body.includes(candidateFingerprintMarker(fingerprint))) { - throw new ContractError(`release ${pyRepr(tag)} does not record the fingerprint of the bytes it actually published`); - } - const binding = validateCandidateManifest(manifest, { - provenance, - correlationId, - expectedReleaseTag: tag, - expectedBridgeSourceSha: compatibility !== null ? LEGACY_MANUAL_QUALIFICATION_BRIDGE_COMMIT : null, - }); - const recordedArtifacts = pyGet(manifest, 'artifacts'); - for (const name of ARTIFACTS) { - const recorded = pyGet(recordedArtifacts, name); - if (!pyEquals(pyGet(assets.get(name), 'digest'), `sha256:${pyStr(pyGet(recorded, 'sha256'))}`)) { - throw new ContractError(`release asset ${pyRepr(name)} digest is not the manifest digest`); - } - if (!pyEquals(pyGet(assets.get(name), 'size'), pyGet(recorded, 'size_bytes'))) { - throw new ContractError(`release asset ${pyRepr(name)} size is not the manifest size`); - } - } - validateReleaseAttestation(gateway.releaseAttestation({ repository: ASSETS_REPOSITORY, releaseTag: tag }), { - assetsRepo: ASSETS_REPOSITORY, - releaseTag: tag, - tagCommit, - releaseId: resolvedId, - expectedAssets: expectedAssetDigests, - }); - return new PublishedRelease({ - releaseId: resolvedId, - releaseTarget: binding.releaseTarget, - binding, - publishedAt, - directory, - }); -} diff --git a/scripts/release/orchestrator/cli.mjs b/scripts/release/orchestrator/cli.mjs deleted file mode 100644 index 87ca6fc..0000000 --- a/scripts/release/orchestrator/cli.mjs +++ /dev/null @@ -1,693 +0,0 @@ -#!/usr/bin/env node -// Artifact-driven automatic stable Web bridge release state machine: the CLI -// entry that .github/workflows/auto_llama_cpp_update.yml runs. It replaced -// the Python scripts/stable_release_orchestrator.py. -// -// Each event-driven scan resolves every stable native release after the -// immutable automation baseline, then idempotently advances each three-stage -// pipeline. A native release older than one a published asset release -// already records is superseded, not rebuilt. A daily scheduled scan provides -// repair fallback: -// -// 1. Build Exact Bridge Candidate (.github/workflows/bridge_candidate.yml) -// 2. Qualify Exact Bridge Candidate (.github/workflows/bridge_qualification.yml) -// 3. Publish Exact Qualified Assets (.github/workflows/publish_assets.yml) -// -// Every transition is proven from downloaded artifact and release bytes. The -// live actions/runs API never echoes a run's dispatch inputs, so pipeline -// state is carried by a deterministic run-name that each workflow renders -// from its own exact inputs, and every named run is then re-proven against its -// run record, its unique artifact, and that artifact's contents before it -// advances anything. -// -// The state machine lives in the sibling modules; this file keeps the -// governed-path classifier (the source of truth that AGENTS.md and -// CONTRIBUTING.md name), the bridge source identity it resolves, and the -// command-line surface. A new sibling module must be listed in -// ORCHESTRATION_ONLY_PATHS below and in TOOLING in scripts/ci/ci_scope.mjs. -// -// main(argv, { env, createGateway, stdout, stderr }) is the entry the tests -// drive: `env` replaces process.env, createGateway({ readToken, -// dispatchToken, env }) replaces the GhGateway constructor, and stdout and -// stderr receive text. It returns the exit status; a ContractError, an -// argparse exit or any other Python exception escapes to the caller, as it -// escapes Python's main() (runMain reports them the way the Python script's -// `__main__` block and interpreter do). - -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import process from 'node:process'; - -import { ArgparseExit, parseArguments, progName } from '../cli.mjs'; -import { ContractError, strictJsonLoads } from '../contract.mjs'; -import { - PyException, compareCodePoints, isDict, pyDecodeUtf8, pyGet, pyIsDir, pyIsSymlink, pyJoinPath, pyJsonDumps, pyKeys, - pyOSError, pyPath, pyReadText, pyRepr, pySplitlines, pyStr, pyStrip, pyUniversalNewlines, -} from '../json.mjs'; -import { isOSError, osErrorString, pyCompareIntTuples, pyFullmatch } from '../python_compat.mjs'; -import { advancePipeline } from './driver.mjs'; -import { - COMMIT_RE, NativeProvenance, OrchestrationAction, REPOSITORY_OWNER, requireStableProvenance, -} from './model.mjs'; -import { - CHANNELS, nativeReleaseOrder, scanNativeProvenance, selectStableNativeBacklog, -} from './native.mjs'; -import { computeCorrelationId } from './run_names.mjs'; -import { makeDirectories } from './stage_proofs.mjs'; -import { GhGateway } from './transport.mjs'; - -// These files can change how a release is discovered, qualified, or -// published, but they do not change the runtime/build inputs placed in the -// bridge artifact. Everything not explicitly classified here is governed by -// default so a newly added build input cannot silently inherit an older -// release identity. This is the source of truth for the classifier. A path -// stays listed after its file is deleted: history must not classify the -// deleting commit as a build input. -export const ORCHESTRATION_ONLY_PATHS = Object.freeze(new Set([ - '.gitignore', - '.github/workflows/auto_llama_cpp_update.yml', - '.github/workflows/bridge_qualification.yml', - '.github/workflows/ci.yml', - '.github/workflows/publish_assets.yml', - 'AGENTS.md', - 'CONTRIBUTING.md', - 'LICENSE', - 'README.md', - // Deleted with the Node port of verify_ci_reliability, which was their - // last importer. - 'scripts/bridge_js_source.py', - 'scripts/bridge_operation_queue_direct_cases.mjs', - 'scripts/bridge_operation_queue_fixtures.mjs', - 'scripts/bridge_operation_queue_lifecycle_contract_cases.mjs', - 'scripts/bridge_operation_queue_worker_proxy_cases.mjs', - // Shared by the scripts/*_browser_smoke.mjs harnesses, which the - // _browser_smoke.mjs suffix covers. Moved to scripts/smoke/support.mjs. - 'scripts/browser_smoke_support.mjs', - // Ported to scripts/ci_scope.mjs; the .py path was deleted. Moved to - // scripts/ci/ci_scope.mjs. - 'scripts/ci_scope.mjs', - 'scripts/ci_scope.py', - // The CI change selector and the CI reliability contract. - 'scripts/ci/ci_scope.mjs', - 'scripts/ci/verify_ci_reliability.mjs', - // Deleted with the Node port of mtmd_compat_contract_test.py, its last - // importer; tests/bridge/native_core_source.mjs is its twin. - 'scripts/native_core_source.py', - // Deleted with the Node port of verify_ci_reliability; see above. - 'scripts/orchestrator_source.py', - // The Python release orchestrator, deleted when the scan workflow switched - // to the Node orchestrator below. Its tests, and those of every module, - // match the _test.py suffix. - 'scripts/release_orchestrator_asset_releases.py', - 'scripts/release_orchestrator_driver.py', - 'scripts/release_orchestrator_model.py', - 'scripts/release_orchestrator_native.py', - 'scripts/release_orchestrator_planner.py', - 'scripts/release_orchestrator_release_tags.py', - 'scripts/release_orchestrator_run_names.py', - 'scripts/release_orchestrator_stage_proofs.py', - 'scripts/release_orchestrator_transport.py', - 'scripts/release_orchestrator_workflow_runs.py', - 'scripts/release_publication_state.py', - 'scripts/release_qualification.py', - // Node ports of the two above. The shared json/cli/errors/contract/manifest - // modules stay governed, like release_contract.py and - // generate_release_manifest.py. - 'scripts/release/archive.mjs', - 'scripts/release/publication_state.mjs', - 'scripts/release/qualification.mjs', - 'scripts/release/qualify.mjs', - 'scripts/release/wav.mjs', - // The Node release orchestrator. Each module is listed explicitly, never by - // prefix. - 'scripts/release/orchestrator/asset_releases.mjs', - 'scripts/release/orchestrator/cli.mjs', - 'scripts/release/orchestrator/driver.mjs', - 'scripts/release/orchestrator/model.mjs', - 'scripts/release/orchestrator/native.mjs', - 'scripts/release/orchestrator/planner.mjs', - 'scripts/release/orchestrator/release_tags.mjs', - 'scripts/release/orchestrator/run_names.mjs', - 'scripts/release/orchestrator/stage_proofs.mjs', - 'scripts/release/orchestrator/transport.mjs', - 'scripts/release/orchestrator/workflow_runs.mjs', - // The browser smokes, moved from scripts/*_browser_smoke.mjs, and the module - // they share. Each is listed explicitly, never by prefix. - 'scripts/smoke/decision.mjs', - 'scripts/smoke/grammar.mjs', - 'scripts/smoke/lora_adapter.mjs', - 'scripts/smoke/multimodal.mjs', - 'scripts/smoke/next_token_scores.mjs', - 'scripts/smoke/speculative.mjs', - 'scripts/smoke/speech_to_text.mjs', - 'scripts/smoke/state_persistence.mjs', - 'scripts/smoke/support.mjs', - 'scripts/smoke/text_to_speech.mjs', - // The speech gate's audio pin and transcript, read by - // release_qualification.py and the speech smoke. Like both of them it - // decides how a candidate is qualified and is never in the artifact. - 'scripts/smoke/speech_to_text_fixture.json', - // Its path before the move to scripts/smoke/. - 'scripts/speech_to_text_fixture.json', - // The Python orchestrator's CLI entry; see the modules above. - 'scripts/stable_release_orchestrator.py', - // Ported to scripts/verify_ci_reliability.mjs; the .py path was deleted. - // Moved to scripts/ci/verify_ci_reliability.mjs. - 'scripts/verify_ci_reliability.mjs', - 'scripts/verify_ci_reliability.py', - // Ported to tests/js/*_api_contract_test.mjs, which the _test.mjs suffix - // covers; the .py paths were deleted. - 'scripts/verify_decision_api.py', - 'scripts/verify_state_persistence_api.py', - 'scripts/verify_text_to_speech_api.py', - // Home of the scripts/bridge_operation_queue_* fixtures above, whose - // scripts/ paths were deleted, until tests/js/ moved to tests/bridge/. - 'tests/js/bridge_operation_queue_direct_cases.mjs', - 'tests/js/bridge_operation_queue_fixtures.mjs', - 'tests/js/bridge_operation_queue_lifecycle_contract_cases.mjs', - 'tests/js/bridge_operation_queue_worker_proxy_cases.mjs', - // Source readers the JS contract tests import, before the same move. - 'tests/js/bridge_js_source.mjs', - 'tests/js/native_core_source.mjs', - // The same fixtures and source readers in tests/bridge/. - 'tests/bridge/bridge_operation_queue_direct_cases.mjs', - 'tests/bridge/bridge_operation_queue_fixtures.mjs', - 'tests/bridge/bridge_operation_queue_lifecycle_contract_cases.mjs', - 'tests/bridge/bridge_operation_queue_worker_proxy_cases.mjs', - 'tests/bridge/bridge_js_source.mjs', - 'tests/bridge/native_core_source.mjs', -])); -// The release tooling's Node tests and their fixtures never reach a build. -export const ORCHESTRATION_ONLY_PREFIXES = Object.freeze(['docs/', 'tests/release/']); -export const ORCHESTRATION_ONLY_SCRIPT_SUFFIXES = Object.freeze([ - // Node ports of the Python browser smokes. Keep the .py suffix: the - // commits that port a smoke delete its .py path, and history must not - // classify those commits as build inputs. Keep the .mjs suffix for the - // same reason: the smokes moved to scripts/smoke/, listed above. - '_browser_smoke.mjs', - '_browser_smoke.py', - '_test.mjs', - '_test.py', -]); -export const ORCHESTRATION_ONLY_JS_TEST_SUFFIX = '_test.mjs'; - -// Return whether a repository path can change published bridge artifacts. -// -// The default is deliberately governed. Only known workflow, validation, -// test, and documentation surfaces are exempt, so deleting this classifier or -// adding a new build input makes the identity advance rather than silently -// reusing an older immutable release. -export function isGovernedBridgePath(path) { - if ( - typeof path !== 'string' - || !path - || path.startsWith('/') - || path.includes('\\') - || path.split('/').some((part) => part === '' || part === '.' || part === '..') - ) { - throw new ContractError(`git reported an invalid repository path: ${pyRepr(path)}`); - } - if (ORCHESTRATION_ONLY_PATHS.has(path)) return false; - if (ORCHESTRATION_ONLY_PREFIXES.some((prefix) => path.startsWith(prefix))) return false; - if (path.startsWith('scripts/') && ORCHESTRATION_ONLY_SCRIPT_SUFFIXES.some((suffix) => path.endsWith(suffix))) return false; - if (path.startsWith('tests/') && path.endsWith(ORCHESTRATION_ONLY_JS_TEST_SUFFIX)) return false; - return true; -} - -// Current executable source plus its governed runtime/build identity. -export class BridgeSourceIdentity { - constructor({ bridgeSourceSha, bridgeBuildSha }) { - this.bridgeSourceSha = bridgeSourceSha; - this.bridgeBuildSha = bridgeBuildSha; - Object.freeze(this); - } -} - -// str(subprocess.CalledProcessError): "Command '(...)' returned non-zero exit -// status N." or "... died with .". -function calledProcessErrorText(argv, completed) { - const command = `(${argv.map((arg) => pyRepr(arg)).join(', ')}${argv.length === 1 ? ',' : ''})`; - if (completed.signal) { - const number = os.constants.signals[completed.signal]; - return `Command '${command}' died with .`; - } - return `Command '${command}' returned non-zero exit status ${completed.status}.`; -} - -// _git_output: subprocess.run(("git", "-C", repository, *args), check=True, -// text=True).stdout, with a failure as ContractError. -export function gitOutput(repository, ...args) { - const argv = ['git', '-C', String(repository), ...args]; - const completed = spawnSync(argv[0], argv.slice(1), { - stdio: ['inherit', 'pipe', 'pipe'], - maxBuffer: 1024 * 1024 * 1024, - }); - let detail = ''; - let fallback = ''; - if (completed.error) { - if (!isOSError(completed.error)) throw completed.error; - fallback = osErrorString(completed.error, 'git'); - } else if (completed.status !== 0) { - detail = pyStrip(pyUniversalNewlines(pyDecodeUtf8(completed.stderr))); - fallback = calledProcessErrorText(argv, completed); - } else { - return pyUniversalNewlines(pyDecodeUtf8(completed.stdout)); - } - throw new ContractError(`could not resolve governed bridge source with git: ${detail || fallback}`); -} - -// Resolve HEAD and the newest first-parent governed build change. -// -// The scheduled checkout must have complete history. Walking first-parent -// makes the selected build identity a commit on the exact default-branch -// line, including merge commits that introduce governed files. `repository` -// is a path string. -export function resolveBridgeSourceIdentity(repository, head = 'HEAD') { - const root = pyPath(String(repository)); - if (!pyIsDir(root) || pyIsSymlink(root)) throw new ContractError(`bridge repository is not a directory: ${root}`); - const sourceSha = pyStrip(gitOutput(root, 'rev-parse', '--verify', `${head}^{commit}`)); - if (pyFullmatch(COMMIT_RE, sourceSha) === null) { - throw new ContractError('resolved bridge source is not a full lowercase commit SHA'); - } - const history = gitOutput( - root, - 'log', - '--first-parent', - '--format=%x00%H%x00', - '--name-only', - '--no-renames', - sourceSha, - ); - const fields = history.split('\0'); - if (fields.length === 0 || fields[0] !== '' || fields.length < 3 || fields.length % 2 === 0) { - throw new ContractError('git returned malformed bridge first-parent history'); - } - for (let index = 1; index < fields.length; index += 2) { - const commit = fields[index]; - if (pyFullmatch(COMMIT_RE, commit) === null) throw new ContractError('git returned a malformed first-parent commit'); - const changedPaths = pySplitlines(fields[index + 1]).filter((path) => path); - if (changedPaths.some((path) => isGovernedBridgePath(path))) { - return new BridgeSourceIdentity({ bridgeSourceSha: sourceSha, bridgeBuildSha: commit }); - } - } - throw new ContractError('bridge history contains no governed runtime/build source'); -} - -// Keep untrusted callers from turning the environment PAT into a deputy. -// -// Scheduled executions are authorized by the trusted default-branch workflow. -// A workflow_run continuation and a manual dispatch additionally require both -// GitHub actor identities to be the repository owner before any -// environment-scoped credential is used. A workflow_run event always executes -// the default-branch workflow definition, so the continuation cannot be -// redefined from a pull request or a fork. -export function requireOrchestrationCaller(eventName, actor, triggeringActor) { - if (eventName === 'schedule') return; - if ( - (eventName === 'workflow_dispatch' || eventName === 'workflow_run') - && actor === REPOSITORY_OWNER - && triggeringActor === REPOSITORY_OWNER - ) { - return; - } - throw new ContractError( - 'stable orchestration requires a schedule event, or an owner-initiated ' - + 'workflow_dispatch or workflow_run continuation with owner actor and ' - + 'triggering_actor', - ); -} - -export function renderStepSummary(plan) { - const target = plan.releaseTarget; - const provenance = plan.provenance; - const lines = [ - '### Stable Web bridge release orchestration', - '', - `- Action: \`${plan.action}\``, - `- Reason: ${plan.reason}`, - `- Correlation: \`${plan.correlationId}\``, - `- Bridge source: \`${provenance.bridgeSourceSha}\``, - `- Governed bridge build: \`${provenance.bridgeBuildSha}\``, - `- llama.cpp: \`${provenance.upstreamTag}@${provenance.upstreamCommit}\``, - `- Native release: \`${provenance.nativeRepo}@${provenance.nativeReleaseTag}\``, - `- Native manifest SHA-256: \`${provenance.nativeManifestSha256}\``, - ]; - if (target !== null) { - lines.push(`- Output release: \`${pyStr(target.releaseTag)}\` (rebuild \`${pyStr(target.releaseRebuild)}\`)`); - } - if (plan.candidateRunId) lines.push(`- Candidate run: \`${plan.candidateRunId}\``); - if (plan.qualificationRunId) lines.push(`- Qualification run: \`${plan.qualificationRunId}\``); - if (plan.inFlightWorkflow) lines.push(`- In flight: \`${plan.inFlightWorkflow}\` run \`${pyStr(plan.inFlightRunId)}\``); - if (plan.dispatchWorkflow) { - lines.push( - `- Dispatched: \`${plan.dispatchWorkflow}\` at \`${pyStr(plan.dispatchRef)}\` ` - + `as \`${pyStr(plan.dispatchRunName)}\` (run \`${pyStr(plan.dispatchedRunId)}\`)`, - ); - } - lines.push(''); - return lines.join('\n'); -} - -const FAILING_ACTIONS = Object.freeze(new Set([OrchestrationAction.BLOCKED])); - -const required = (flag, type = 'str') => ({ flag, required: true, type }); -const optional = (flag, type = 'str', fallback = null) => ({ - flag, required: false, type, default: fallback, -}); - -// Each subcommand's options, as parseArguments takes them, and each option's -// argparse `choices`. -export const COMMANDS = Object.freeze({ - 'scan-native': { - options: [ - required('--manifest', 'path'), - required('--native-release-tag'), - required('--bridge-source-sha'), - required('--bridge-build-sha'), - required('--native-release-published-at'), - required('--channel'), - optional('--output-json', 'path'), - ], - choices: { channel: ['--channel', [...CHANNELS.keys()].sort(compareCodePoints)] }, - }, - 'resolve-bridge-source': { - options: [required('--repository', 'path'), optional('--head', 'str', 'HEAD'), optional('--output-json', 'path')], - }, - 'select-stable-native-backlog': { - options: [required('--releases-json', 'path'), optional('--output-json', 'path')], - }, - orchestrate: { - options: [ - required('--provenance-json', 'path'), - required('--workspace', 'path'), - optional('--output-plan-json', 'path'), - optional('--step-summary-file', 'path'), - optional('--dry-run', 'flag'), - ], - }, - 'orchestrate-backlog': { - options: [ - required('--provenance-list-json', 'path'), - required('--workspace', 'path'), - optional('--output-plan-json', 'path'), - optional('--step-summary-file', 'path'), - optional('--dry-run', 'flag'), - ], - }, -}); - -const PROG = progName(import.meta.url); - -function usageError(prog, message) { - throw new ArgparseExit(2, { stderr: `usage: ${prog} [-h] {${Object.keys(COMMANDS).join(',')}} ...\n${prog}: error: ${message}\n` }); -} - -// argparse with add_subparsers(dest="subcommand", required=True), under -// cli.mjs's policy (stricter, never looser): the subcommand must be the first -// argument, and the rest is parsed by parseArguments. Returns -// { subcommand, args } with camelCase keys. -export function parseCommand(argv) { - if (argv.length === 0) usageError(PROG, 'the following arguments are required: subcommand'); - const [subcommand, ...rest] = argv; - if (subcommand === '-h' || subcommand === '--help') { - throw new ArgparseExit(0, { stdout: `usage: ${PROG} [-h] {${Object.keys(COMMANDS).join(',')}} ...\n` }); - } - if (!Object.hasOwn(COMMANDS, subcommand)) { - usageError(PROG, `argument subcommand: invalid choice: ${pyRepr(subcommand)} (choose from ${Object.keys(COMMANDS).map(pyRepr).join(', ')})`); - } - const spec = COMMANDS[subcommand]; - const prog = `${PROG} ${subcommand}`; - const args = parseArguments(rest, { prog, options: spec.options }); - for (const [key, [flag, choices]] of Object.entries(spec.choices ?? {})) { - if (args[key] !== null && !choices.includes(args[key])) { - throw new ArgparseExit(2, { - stderr: `usage: ${prog} ...\n${prog}: error: argument ${flag}: invalid choice: ${pyRepr(args[key])} (choose from ${choices.map(pyRepr).join(', ')})\n`, - }); - } - } - return { subcommand, args }; -} - -// _provenance_to_dict. -export function provenanceToDict(provenance) { - return { - bridge_source_sha: provenance.bridgeSourceSha, - bridge_build_sha: provenance.bridgeBuildSha, - upstream_tag: provenance.upstreamTag, - upstream_commit: provenance.upstreamCommit, - native_repo: provenance.nativeRepo, - native_release_tag: provenance.nativeReleaseTag, - native_commit: provenance.nativeCommit, - native_manifest_sha256: provenance.nativeManifestSha256, - native_release_published_at: provenance.nativeReleasePublishedAt, - }; -} - -const PROVENANCE_FIELDS = Object.freeze([ - ['bridge_source_sha', 'bridgeSourceSha'], - ['bridge_build_sha', 'bridgeBuildSha'], - ['upstream_tag', 'upstreamTag'], - ['upstream_commit', 'upstreamCommit'], - ['native_repo', 'nativeRepo'], - ['native_release_tag', 'nativeReleaseTag'], - ['native_commit', 'nativeCommit'], - ['native_manifest_sha256', 'nativeManifestSha256'], - ['native_release_published_at', 'nativeReleasePublishedAt'], -]); - -// _load_provenance_payload. -export function loadProvenancePayload(payload, label) { - if (!isDict(payload) && !(payload instanceof Map)) throw new ContractError(`${label} must be a JSON object`); - const keys = pyKeys(payload); - if (keys.length !== PROVENANCE_FIELDS.length || !PROVENANCE_FIELDS.every(([snake]) => keys.includes(snake))) { - throw new ContractError(`${label} has missing or unexpected fields`); - } - const options = Object.fromEntries(PROVENANCE_FIELDS.map(([snake, camel]) => [camel, pyGet(payload, snake)])); - return requireStableProvenance(new NativeProvenance(options)); -} - -// _load_provenance: `path` is a path string. -export function loadProvenance(path) { - const payload = strictJsonLoads(pyReadText(String(path)), 'provenance'); - return loadProvenancePayload(payload, 'provenance'); -} - -// _load_provenance_backlog: the provenances in (published_at, native order). -export function loadProvenanceBacklog(path) { - const payload = strictJsonLoads(pyReadText(String(path)), 'provenance backlog'); - if (!Array.isArray(payload)) throw new ContractError('provenance backlog root must be a JSON array'); - const provenances = payload.map((item, index) => loadProvenancePayload(item, `provenance backlog entry ${index}`)); - const correlations = new Set(); - const nativeTags = new Set(); - for (const provenance of provenances) { - const correlation = computeCorrelationId(provenance); - if (correlations.has(correlation) || nativeTags.has(provenance.nativeReleaseTag)) { - throw new ContractError('provenance backlog contains a duplicate pipeline'); - } - correlations.add(correlation); - nativeTags.add(provenance.nativeReleaseTag); - } - const keyed = provenances.map((value) => [value.nativeReleasePublishedAt, nativeReleaseOrder(value.nativeReleaseTag), value]); - keyed.sort((left, right) => compareCodePoints(left[0], right[0]) || pyCompareIntTuples(left[1], right[1])); - return keyed.map(([, , value]) => value); -} - -// Path.write_text(text, encoding="utf-8"). -function writeText(path, text) { - try { - fs.writeFileSync(path, text, 'utf8'); - } catch (error) { - throw pyOSError(error, path); - } -} - -// open(path, "a", encoding="utf-8").write(text). -function appendText(path, text) { - try { - fs.appendFileSync(path, text, 'utf8'); - } catch (error) { - throw pyOSError(error, path); - } -} - -function defaultCreateGateway({ readToken, dispatchToken, env }) { - return new GhGateway({ readToken, dispatchToken, env }); -} - -export function main(argv = process.argv.slice(2), { - env = process.env, - createGateway = defaultCreateGateway, - stdout = (text) => process.stdout.write(text), - stderr = (text) => process.stderr.write(text), -} = {}) { - const { subcommand, args } = parseCommand(argv); - const print = (text) => stdout(`${text}\n`); - - if (subcommand === 'scan-native') { - const provenance = scanNativeProvenance({ - manifestPath: args.manifest, - nativeReleaseTag: args.nativeReleaseTag, - bridgeSourceSha: args.bridgeSourceSha, - bridgeBuildSha: args.bridgeBuildSha, - channel: args.channel, - nativeReleasePublishedAt: args.nativeReleasePublishedAt, - }); - const payload = pyJsonDumps(provenanceToDict(provenance), { indent: 2, sortKeys: true }); - if (args.outputJson) writeText(args.outputJson, `${payload}\n`); - print(payload); - return 0; - } - - if (subcommand === 'resolve-bridge-source') { - const identity = resolveBridgeSourceIdentity(args.repository, args.head); - const payload = pyJsonDumps({ - bridge_source_sha: identity.bridgeSourceSha, - bridge_build_sha: identity.bridgeBuildSha, - }, { indent: 2, sortKeys: true }); - if (args.outputJson) writeText(args.outputJson, `${payload}\n`); - print(payload); - return 0; - } - - if (subcommand === 'select-stable-native-backlog') { - const releases = strictJsonLoads(pyReadText(String(args.releasesJson)), 'native release listing'); - if (!Array.isArray(releases)) throw new ContractError('native release listing must be a JSON array'); - const selected = selectStableNativeBacklog(releases); - const payload = pyJsonDumps(selected, { indent: 2 }); - if (args.outputJson) writeText(args.outputJson, `${payload}\n`); - print(payload); - return 0; - } - - requireOrchestrationCaller(env.GITHUB_EVENT_NAME ?? '', env.GITHUB_ACTOR ?? '', env.GITHUB_TRIGGERING_ACTOR ?? ''); - const gateway = createGateway({ - readToken: env.GH_TOKEN ?? '', - dispatchToken: env.WEBGPU_BRIDGE_ASSETS_PAT ?? null, - env, - }); - makeDirectories(args.workspace); - - if (subcommand === 'orchestrate-backlog') { - const provenances = loadProvenanceBacklog(args.provenanceListJson); - const plans = []; - const errors = []; - const reservedReleaseTags = new Set(); - const satisfiedCorrelationIds = new Set(); - let publicationBarrierNativeTag = null; - let newestNativeOrder = []; - for (const provenance of provenances) { - const order = nativeReleaseOrder(provenance.nativeReleaseTag); - if (pyCompareIntTuples(order, newestNativeOrder) > 0) newestNativeOrder = order; - } - for (const provenance of provenances) { - const correlationId = computeCorrelationId(provenance); - const pipelineWorkspace = pyJoinPath(args.workspace, correlationId); - makeDirectories(pipelineWorkspace); - try { - const plan = advancePipeline(gateway, { - provenance, - workspace: pipelineWorkspace, - dryRun: args.dryRun, - reservedReleaseTags, - satisfiedCorrelationIds, - publicationAllowed: publicationBarrierNativeTag === null, - publicationBarrierNativeTag, - newerNativeScanned: pyCompareIntTuples(nativeReleaseOrder(provenance.nativeReleaseTag), newestNativeOrder) < 0, - scannedProvenances: provenances, - }); - plans.push(plan.toDict()); - if (plan.action === OrchestrationAction.SATISFIED_BY_IDENTICAL_RELEASE) { - satisfiedCorrelationIds.add(correlationId); - } else if (plan.releaseTarget !== null) { - reservedReleaseTags.add(plan.releaseTarget.releaseTag); - } - if (publicationBarrierNativeTag === null && ![ - OrchestrationAction.NOOP, - OrchestrationAction.SUPERSEDED, - OrchestrationAction.SATISFIED_BY_IDENTICAL_RELEASE, - ].includes(plan.action)) { - publicationBarrierNativeTag = provenance.nativeReleaseTag; - } - if (args.stepSummaryFile) appendText(args.stepSummaryFile, `${renderStepSummary(plan)}\n`); - } catch (error) { - if (!(error instanceof ContractError)) throw error; - errors.push({ - correlation_id: correlationId, - native_release_tag: provenance.nativeReleaseTag, - error: error.message, - }); - if (args.stepSummaryFile) { - appendText( - args.stepSummaryFile, - '### Blocked stable Web bridge release orchestration\n\n' - + `- Correlation: \`${correlationId}\`\n` - + `- Native release: \`${provenance.nativeReleaseTag}\`\n` - + `- Error: ${error.message}\n\n`, - ); - } - // A transport/readback error can mean a dispatch occurred but its - // state is not yet observable. Stop before another backlog entry can - // claim a colliding output identity under uncertainty. - break; - } - } - const result = { schema_version: 1, plans, errors }; - const payload = pyJsonDumps(result, { indent: 2, sortKeys: true }); - if (args.outputPlanJson) writeText(args.outputPlanJson, `${payload}\n`); - print(payload); - const blocked = plans.filter((plan) => FAILING_ACTIONS.has(plan.action)); - if (errors.length > 0 || blocked.length > 0) { - stderr('error: one or more stable pipelines are blocked\n'); - return 1; - } - return 0; - } - - const provenance = loadProvenance(args.provenanceJson); - const plan = advancePipeline(gateway, { - provenance, - workspace: args.workspace, - dryRun: args.dryRun, - }); - const payload = pyJsonDumps(plan.toDict(), { indent: 2, sortKeys: true }); - if (args.outputPlanJson) writeText(args.outputPlanJson, `${payload}\n`); - if (args.stepSummaryFile) appendText(args.stepSummaryFile, `${renderStepSummary(plan)}\n`); - print(payload); - if (FAILING_ACTIONS.has(plan.action)) { - stderr(`error: ${plan.reason}\n`); - return 1; - } - return 0; -} - -// Run main() as `python3 scripts/stable_release_orchestrator.py` runs: a -// ContractError prints "error: " and exits 1 (the __main__ block's -// SystemExit), an argparse exit prints its text with its status, and any -// other Python exception prints its traceback's last line and exits 1. -// Returns the exit status. -export function runMain(argv = process.argv.slice(2), options = {}) { - const stdout = options.stdout ?? ((text) => process.stdout.write(text)); - const stderr = options.stderr ?? ((text) => process.stderr.write(text)); - try { - const status = main(argv, { ...options, stdout, stderr }); - return Number.isInteger(status) ? status : 0; - } catch (error) { - if (error instanceof ArgparseExit) { - if (error.stdout) stdout(error.stdout); - if (error.stderr) stderr(error.stderr); - return error.status; - } - if (error instanceof ContractError) { - stderr(`error: ${error.message}\n`); - return 1; - } - if (error instanceof PyException) { - stderr(`Traceback (most recent call last):\n${error.tracebackLine}\n`); - return 1; - } - throw error; - } -} - -if (import.meta.main) process.exitCode = runMain(); diff --git a/scripts/release/orchestrator/driver.mjs b/scripts/release/orchestrator/driver.mjs deleted file mode 100644 index 22c6bd5..0000000 --- a/scripts/release/orchestrator/driver.mjs +++ /dev/null @@ -1,990 +0,0 @@ -// Live driver of the stable release orchestrator, the Node port of -// scripts/release_orchestrator_driver.py. -// -// Proves repository governance, gathers the evidence one pipeline already -// has, plans its single next transition, and dispatches it with a -// deterministic run-name readback. - -import { - ASSETS_REPOSITORY, BRIDGE_REPOSITORY, Channel, ContractError, NATIVE_REPOSITORY, parseReleaseTag, - validateImmutableReleaseGovernance, validatePublicationEnvironment, -} from '../contract.mjs'; -import { - isDict, pyGet, pyJoinPath, pyPath, pyRepr, pyStr, -} from '../json.mjs'; -import { candidatePublicationDigests } from '../publication_state.mjs'; -import { pyCompareIntTuples } from '../python_compat.mjs'; -import { - correlationMarker, fetchAssetReleases, fetchAssetTagNames, findCorrelatedRelease, latestAlignedRelease, - latestPublishedNativeAlignment, newestPublishedStableRelease, pages, publicationBytesIdentical, verifyPublishedRelease, - workflowHistorySince, -} from './asset_releases.mjs'; -import { - CANDIDATE_WORKFLOW_FILE, CANDIDATE_WORKFLOW_PATH, OrchestrationAction, OrchestrationPlan, PUBLISH_WORKFLOW_FILE, - PUBLISH_WORKFLOW_PATH, PipelineBinding, PipelineObservation, QUALIFICATION_WORKFLOW_FILE, QUALIFICATION_WORKFLOW_PATH, - REPOSITORY_OWNER, requireStableProvenance, requireStr, -} from './model.mjs'; -import { nativeReleaseOrder } from './native.mjs'; -import { WORKFLOW_DISPATCH_INPUTS, planPipeline, requireExactDispatchInputs } from './planner.mjs'; -import { - candidateTagClaim, claimNamesOtherBuild, claimedReleaseTags, claimedRebuildsOf, claimsNotBelow, hasOtherBuildClaim, - releaseTagClaims, runNameCorrelationId, selectNextReleaseTarget, -} from './release_tags.mjs'; -import { - computeCorrelationId, correlationNativeIdentity, correlationRecoveryBase, parsePublishRunName, publishRunName, - qualificationRunName, -} from './run_names.mjs'; -import { makeDirectories, verifyCandidateRun, verifyQualificationRun } from './stage_proofs.mjs'; -import { - candidateMatcher, fetchRecentRuns, fetchRuns, findNamedRun, mergeRunRecords, resolveCandidateBinding, - selectPipelineRuns, -} from './workflow_runs.mjs'; - -// isinstance(value, Mapping) for a JSON value. -function isMapping(value) { - return isDict(value) || value instanceof Map; -} - -export const DISPATCH_READBACK_ATTEMPTS = 12; -export const DISPATCH_READBACK_DELAY_SECONDS = 5.0; -// Each recovery generation follows a further publication that overtook the -// previous one; more than this means output-tag order keeps breaking, which a -// maintainer must diagnose. -export const MAX_RECOVERY_GENERATIONS = 3; - -export function requireDefaultBranch(gateway) { - const payload = gateway.apiJson(`repos/${BRIDGE_REPOSITORY}`); - if (!isMapping(payload)) throw new ContractError('repository response must be a JSON object'); - return requireStr(pyGet(payload, 'default_branch'), 'default_branch'); -} - -// Prove immutable-release governance live, never by constant assertion. -export function requireImmutableReleaseGovernance(gateway) { - const payload = gateway.apiJson(`repos/${ASSETS_REPOSITORY}/immutable-releases`, { privileged: true }); - return validateImmutableReleaseGovernance(payload, ASSETS_REPOSITORY); -} - -// Prove the existing environment policy before asserting publication approval. -export function requirePublicationEnvironment(gateway) { - const environment = gateway.apiJson(`repos/${BRIDGE_REPOSITORY}/environments/bridge-assets-publication`); - const branchPolicies = gateway.apiJson( - `repos/${BRIDGE_REPOSITORY}/environments/bridge-assets-publication/deployment-branch-policies`, - ); - if (!isMapping(environment) || !isMapping(branchPolicies)) { - throw new ContractError('publication environment responses must be JSON objects'); - } - validatePublicationEnvironment(environment, branchPolicies); -} - -// Evidence that the run listing a dispatch plan was built from was stale: a -// fresh read of the target stage found runs that contradict the plan. -// `conflicting` are the pipeline's own runs that forbid the dispatch, and -// `claims` are other pipelines' candidate runs, as { record, releaseTag, -// correlationId }, still claiming an output tag at or above a fresh candidate -// binding's. `evidence` maps each workflow file read to the fresh records a -// re-plan merges into its listing. `repeated` marks a re-plan that wanted the -// same stage again despite them. -export class StaleObservation { - constructor({ - workflowFile, plan, evidence, conflicting = [], claims = [], repeated = false, - }) { - this.workflowFile = workflowFile; - this.plan = plan; - this.evidence = new Map([...evidence].map(([file, runs]) => [file, Object.freeze([...runs])])); - this.conflicting = Object.freeze([...conflicting]); - this.claims = Object.freeze([...claims]); - this.repeated = repeated; - Object.freeze(this); - } - - // Whether a re-plan's `plan` is still contradicted by this evidence: it - // dispatches the same stage while the pipeline's own runs forbid it, or - // with an output tag a claim here is not below. - refuses(plan) { - if (plan.dispatchWorkflow !== this.workflowFile) return false; - if (this.conflicting.length > 0) return true; - const releaseTag = plan.releaseTarget?.releaseTag; - return typeof releaseTag === 'string' && claimsNotBelow(releaseTag, this.claims.map((claim) => claim.releaseTag)).length > 0; - } - - // The same evidence, refusing `plan`, a re-plan that still wants this stage. - repeatedBy(plan) { - return new StaleObservation({ - workflowFile: this.workflowFile, - plan, - evidence: this.evidence, - conflicting: this.conflicting, - claims: this.claims, - repeated: true, - }); - } - - describe() { - const state = (record) => (record.inFlight ? record.status : record.conclusion); - const runs = [ - ...this.conflicting.map((record) => `${record.runId} (${state(record)})`), - ...this.claims.map(({ record, releaseTag, correlationId }) => ( - `${record.runId} (${state(record)}, claiming ${releaseTag} for ${correlationId})` - )), - ]; - return `${this.workflowFile} run(s) ${runs.join(', ')}`; - } -} - -// Stages whose failed runs the observation already turns into an automatic -// retry. A failed candidate or qualification run blocks its stage instead -// (see the BLOCKED plans in observeAndPlan), so for those stages a fresh -// failed run contradicts a dispatch plan as much as a live or successful one. -const RETRIED_AFTER_FAILURE_WORKFLOWS = Object.freeze(new Set([PUBLISH_WORKFLOW_FILE])); - -const STAGE_LABELS = Object.freeze(new Map([ - [CANDIDATE_WORKFLOW_FILE, 'candidate'], - [QUALIFICATION_WORKFLOW_FILE, 'qualification'], - [PUBLISH_WORKFLOW_FILE, 'publication'], -])); - -const DOWNSTREAM_WORKFLOWS = Object.freeze([ - [QUALIFICATION_WORKFLOW_FILE, QUALIFICATION_WORKFLOW_PATH], - [PUBLISH_WORKFLOW_FILE, PUBLISH_WORKFLOW_PATH], -]); - -// Which runs of a dispatch plan's stage belong to its pipeline, whatever -// binding they were dispatched with: any candidate of the correlation, and a -// qualification or publication of the correlation's exact candidate run. -export function stageCorrelationMatcher(plan) { - const { correlationId, candidateRunId } = plan; - if (plan.dispatchWorkflow === CANDIDATE_WORKFLOW_FILE) return candidateMatcher(correlationId); - if (plan.dispatchWorkflow === QUALIFICATION_WORKFLOW_FILE) { - const expected = qualificationRunName(correlationId, requireStr(candidateRunId, 'candidate_run_id')); - return (name) => name === expected; - } - if (plan.dispatchWorkflow === PUBLISH_WORKFLOW_FILE) { - requireStr(candidateRunId, 'candidate_run_id'); - return (name) => { - const parsed = parsePublishRunName(name, correlationId); - return parsed !== null && parsed[0] === candidateRunId; - }; - } - throw new ContractError(`${pyRepr(plan.dispatchWorkflow)} is not a workflow this orchestrator may dispatch`); -} - -// Whether `correlationId` is a pipeline of a native release newer than -// `nativeReleaseTag`. -function namesNewerNative(correlationId, nativeReleaseTag) { - const other = correlationNativeIdentity(correlationId); - return other !== null - && pyCompareIntTuples(nativeReleaseOrder(other.nativeReleaseTag), nativeReleaseOrder(nativeReleaseTag)) > 0; -} - -export function fetchNativeReleases(gateway) { - const payload = gateway.apiJson(`repos/${NATIVE_REPOSITORY}/releases?per_page=100`, { paginate: true }); - const releases = []; - for (const page of pages(payload, 'native releases')) { - for (const release of Array.isArray(page) ? page : [page]) { - if (!isMapping(release)) throw new ContractError('native release record must be a JSON object'); - releases.push(release); - } - } - return releases; -} - -// Why a newer native's tag `claim` proves nothing, or null when it is the -// pipeline of a published stable native release whose assets.json is the -// manifest its correlation names. Native releases are mutable, so a deleted, -// drafted or re-uploaded release leaves a claim that no scan advances. -export function unprovenNativeClaim(nativeReleases, claim) { - const { nativeReleaseTag, nativeManifestPrefix } = correlationNativeIdentity(claim.correlationId); - const subject = ( - `the pipeline of newer native release ${nativeReleaseTag} claims ${claim.releaseTag} ` - + `(correlation ${claim.correlationId})` - ); - const release = nativeReleases.find((record) => pyGet(record, 'tag_name') === nativeReleaseTag); - if ( - release === undefined - || pyGet(release, 'draft') !== false - || pyGet(release, 'prerelease') !== parseReleaseTag(nativeReleaseTag).githubPrerelease - ) { - return `${subject}, but ${nativeReleaseTag} is not a published stable native release`; - } - const assets = pyGet(release, 'assets'); - const manifests = Array.isArray(assets) - ? assets.filter((asset) => isMapping(asset) && pyGet(asset, 'name') === 'assets.json') - : []; - const digest = manifests.length === 1 ? pyGet(manifests[0], 'digest') : null; - if (typeof digest !== 'string' || !digest.startsWith(`sha256:${nativeManifestPrefix}`)) { - return `${subject}, but the published assets.json of ${nativeReleaseTag} is not the manifest that correlation names`; - } - return null; -} - -// Both fresh reads of one workflow, merged: the complete filtered history and -// the newest unfiltered page. `correlated` names the guarded pipeline's own -// runs; `select` other pipelines' runs worth keeping. -function freshStageRuns(gateway, { - workflowFile, workflowPath, defaultBranch, createdSince, correlated, select = () => false, -}) { - const searched = fetchRuns(gateway, { - workflowFile, workflowPath, defaultBranch, createdSince, - }).filter((record) => correlated(record.runName) || select(record.runName)); - const recent = fetchRecentRuns(gateway, { - workflowFile, workflowPath, defaultBranch, correlated, select, - }); - return mergeRunRecords(searched, recent); -} - -// Other pipelines' candidate runs in `candidateRuns` that still claim an -// output tag at or above `releaseTag`, or any output tag for a newer native -// release, as claimedReleaseTags decides for each claiming correlation. A -// claim of another build identity holds only while a run of its correlation -// is in flight, so before such a claim is released the downstream stages are -// read fresh too, and recorded in `evidence`. -function freshTagClaims(gateway, plan, candidateRuns, { - defaultBranch, createdSince, satisfiedCorrelationIds, evidence, -}) { - const { bridgeBuildSha } = plan.provenance; - const releaseTag = plan.releaseTarget.releaseTag; - const byCorrelation = new Map(); - const candidates = []; - for (const record of candidateRuns) { - const claim = candidateTagClaim(record.runName); - if (claim === null || claim.correlationId === plan.correlationId) continue; - if (!byCorrelation.has(claim.correlationId)) byCorrelation.set(claim.correlationId, []); - byCorrelation.get(claim.correlationId).push(record); - if ( - claimsNotBelow(releaseTag, [claim.releaseTag]).length > 0 - || namesNewerNative(claim.correlationId, plan.provenance.nativeReleaseTag) - ) { - candidates.push({ record, ...claim }); - } - } - if (candidates.length === 0) return { claims: [], records: [] }; - const releasable = new Set(candidates - .map(({ correlationId }) => correlationId) - .filter((correlationId) => ( - claimNamesOtherBuild(correlationId, bridgeBuildSha) - && !byCorrelation.get(correlationId).some((record) => record.inFlight) - ))); - const downstreamRuns = []; - if (releasable.size > 0) { - for (const [workflowFile, workflowPath] of DOWNSTREAM_WORKFLOWS) { - const runs = freshStageRuns(gateway, { - workflowFile, - workflowPath, - defaultBranch, - createdSince, - correlated: () => false, - select: (name) => releasable.has(runNameCorrelationId(name)), - }); - evidence.set(workflowFile, runs); - downstreamRuns.push(...runs); - } - } - const claims = candidates.filter(({ correlationId, releaseTag: claimed }) => claimedReleaseTags( - byCorrelation.get(correlationId), - { bridgeBuildSha, downstreamRuns, satisfiedCorrelationIds }, - ).has(claimed)); - const claimants = new Set(claims.map(({ correlationId }) => correlationId)); - return { claims, records: [...claimants].flatMap((correlationId) => byCorrelation.get(correlationId)) }; -} - -// The correlation guard every dispatch passes: read the target stage again, -// through two differently shaped queries (the complete filtered history and -// the newest unfiltered page), and return a StaleObservation when a run of -// this pipeline is live or successful, or has failed at a stage that is never -// retried automatically, or, for a candidate, when another pipeline still -// claims its output tag or a higher one. Returns null when the dispatch is -// still the next step. -export function guardStageCorrelation(gateway, plan, { - defaultBranch, workflowPath, createdSince, satisfiedCorrelationIds = [], -}) { - const workflowFile = plan.dispatchWorkflow; - const matcher = stageCorrelationMatcher(plan); - const isCandidate = workflowFile === CANDIDATE_WORKFLOW_FILE; - const runs = freshStageRuns(gateway, { - workflowFile, - workflowPath, - defaultBranch, - createdSince, - correlated: matcher, - select: isCandidate ? (name) => candidateTagClaim(name) !== null : () => false, - }); - const selection = selectPipelineRuns(runs, { label: STAGE_LABELS.get(workflowFile), matcher, defaultBranch }); - const retried = RETRIED_AFTER_FAILURE_WORKFLOWS.has(workflowFile); - const conflicting = selection.matched.filter((record) => record.inFlight || record.succeeded || !retried); - const evidence = new Map(); - const { claims, records } = isCandidate && conflicting.length === 0 - ? freshTagClaims(gateway, plan, runs, { - defaultBranch, createdSince, satisfiedCorrelationIds, evidence, - }) - : { claims: [], records: [] }; - if (conflicting.length === 0 && claims.length === 0) return null; - evidence.set(workflowFile, [...selection.matched, ...records]); - return new StaleObservation({ - workflowFile, plan, evidence, conflicting, claims, - }); -} - -// A copy of `plan` with `reason`. -function withReason(plan, reason) { - return new OrchestrationPlan(Object.fromEntries(OrchestrationPlan.FIELDS.map(([camel]) => [ - camel, camel === 'reason' ? reason : plan[camel], - ]))); -} - -// Advance one pipeline by at most one stage. `workspace` is a path string; -// reservedReleaseTags and satisfiedCorrelationIds are any iterables. -// -// A dispatch plan is built from run listings that can be stale. When the -// correlation guard finds this pipeline's runs that the listing omitted, the -// pipeline is observed and planned once more with that evidence merged in. A -// re-plan that wants the same stage again, or meets a second stale listing, -// ends as BLOCKED without dispatching; the next scan observes it afresh. -export function advancePipeline(gateway, options) { - const first = observeAndPlan(gateway, options, null); - if (!(first instanceof StaleObservation)) return first; - // The re-plan proves its stages again from fresh downloads, which refuse a - // non-empty destination, so it works in its own directory. - const workspace = pyJoinPath(pyPath(String(options.workspace)), 'replan'); - makeDirectories(workspace); - const second = observeAndPlan(gateway, { ...options, workspace }, first); - const stale = ( - `the ${first.workflowFile} run listing this scan planned ${first.plan.action} from was stale: ` - + `a fresh correlation read found ${first.describe()} that it omitted` - ); - if (!(second instanceof StaleObservation)) return withReason(second, `${second.reason} (re-planned once: ${stale})`); - const target = second.plan.releaseTarget === null ? '' : ` for ${second.plan.releaseTarget.releaseTag}`; - const outcome = second.repeated - ? `re-planned once with that evidence, the pipeline still planned ${second.plan.action}${target}` - : ( - `re-planned once with that evidence, the pipeline planned ${second.plan.action}, ` - + `but a fresh correlation read found ${second.describe()} that its listing omitted too` - ); - return new OrchestrationPlan({ - action: OrchestrationAction.BLOCKED, - reason: `${stale}; ${outcome}; nothing was dispatched, and the next scan observes the pipeline again`, - provenance: second.plan.provenance, - correlationId: second.plan.correlationId, - releaseTarget: second.plan.releaseTarget, - candidateRunId: second.plan.candidateRunId, - qualificationRunId: second.plan.qualificationRunId, - }); -} - -// One observation and plan of advancePipeline. `evidence` is null, or the -// StaleObservation of an earlier pass, whose runs are merged into the listing -// of its stage; a plan that wants that stage again returns it as repeated. -function observeAndPlan(gateway, options, evidence) { - const { - provenance, - workspace, - dryRun = false, - reservedReleaseTags = [], - satisfiedCorrelationIds = [], - publicationAllowed = true, - publicationBarrierNativeTag = null, - newerNativeScanned = false, - scannedProvenances = [], - recovery = 0, - } = options; - requireStableProvenance(provenance); - if (!publicationAllowed) { - if (typeof publicationBarrierNativeTag !== 'string') { - throw new ContractError('a disabled publication transition requires the earlier native tag'); - } - const barrier = parseReleaseTag(publicationBarrierNativeTag); - if (barrier.channel !== Channel.STABLE) throw new ContractError('publication barrier native tag must be stable'); - } - const correlationId = computeCorrelationId(provenance, { recovery }); - const defaultBranch = requireDefaultBranch(gateway); - // Governance is a prerequisite for every state classification, including a - // noop: a disabled or unreadable policy is never reported as healthy stable - // automation merely because an older release happens to exist. - requireImmutableReleaseGovernance(gateway); - - const releases = fetchAssetReleases(gateway); - const correlated = findCorrelatedRelease(releases, correlationId, provenance); - if (correlated !== null) { - const published = verifyPublishedRelease(gateway, { - release: correlated, - provenance, - correlationId, - workspace, - }); - if (!publicationAllowed) { - return new OrchestrationPlan({ - action: OrchestrationAction.BLOCKED, - reason: ( - `${provenance.nativeReleaseTag} is already published while ` - + `earlier native release ${publicationBarrierNativeTag} is not; ` - + 'the ordered publication history is inconsistent' - ), - provenance, - correlationId, - releaseTarget: published.releaseTarget, - }); - } - return planPipeline({ provenance, correlationId, observation: new PipelineObservation({ published }) }); - } - - const alignment = latestPublishedNativeAlignment(releases); - if ( - alignment !== null - && pyCompareIntTuples(nativeReleaseOrder(provenance.nativeReleaseTag), nativeReleaseOrder(alignment[0])) < 0 - ) { - const [alignedNativeTag, alignedAssetTag] = alignment; - if (!newerNativeScanned) { - return new OrchestrationPlan({ - action: OrchestrationAction.BLOCKED, - reason: ( - `asset release ${alignedAssetTag} records native release ` - + `${alignedNativeTag}, newer than ${provenance.nativeReleaseTag}, ` - + 'but no newer stable native release is part of this scan; ' - + 'refusing to skip the newest scanned native release' - ), - provenance, - correlationId, - }); - } - return new OrchestrationPlan({ - action: OrchestrationAction.SUPERSEDED, - reason: ( - `${provenance.nativeReleaseTag} is behind native release ` - + `${alignedNativeTag}, already published as ${alignedAssetTag}; ` - + 'only native releases at or ahead of the published alignment ' - + 'are advanced for a new build identity' - ), - provenance, - correlationId, - }); - } - - const runHistorySince = workflowHistorySince(releases, provenance); - const observedRuns = (workflowFile, workflowPath) => { - const runs = fetchRuns(gateway, { - workflowFile, - workflowPath, - defaultBranch, - createdSince: runHistorySince, - }); - const fresh = evidence?.evidence.get(workflowFile); - return fresh === undefined ? runs : mergeRunRecords(runs, fresh); - }; - const dispatch = (plan, workflowPath) => { - if (evidence !== null && evidence.refuses(plan)) return evidence.repeatedBy(plan); - return executeDispatch(gateway, plan, { - defaultBranch, workflowPath, dryRun, createdSince: runHistorySince, satisfiedCorrelationIds, - }); - }; - const assetTagNames = fetchAssetTagNames(gateway); - const candidateRuns = observedRuns(CANDIDATE_WORKFLOW_FILE, CANDIDATE_WORKFLOW_PATH); - const candidateSelection = selectPipelineRuns(candidateRuns, { - label: 'candidate', - matcher: candidateMatcher(correlationId), - defaultBranch, - }); - const persisted = resolveCandidateBinding(candidateSelection, correlationId); - - const stillClaimed = () => { - const downstreamWorkflows = hasOtherBuildClaim(candidateRuns, { bridgeBuildSha: provenance.bridgeBuildSha }) - ? [[QUALIFICATION_WORKFLOW_FILE, QUALIFICATION_WORKFLOW_PATH], [PUBLISH_WORKFLOW_FILE, PUBLISH_WORKFLOW_PATH]] - : []; - const downstreamRuns = downstreamWorkflows.flatMap(([workflowFile, workflowPath]) => observedRuns(workflowFile, workflowPath)); - return releaseTagClaims(candidateRuns, { - bridgeBuildSha: provenance.bridgeBuildSha, - downstreamRuns, - satisfiedCorrelationIds, - }); - }; - const stillClaimedTags = () => new Set(stillClaimed().map((claim) => claim.releaseTag)); - // Newer native releases' pipelines, whether or not this scan includes them: - // a targeted scan of one older native sees no newer provenance. - const newerNativeClaims = () => stillClaimed() - .filter((claim) => namesNewerNative(claim.correlationId, provenance.nativeReleaseTag)); - // A scanned provenance proves its own claims. Any other claim is held to the - // native repository, read at most once per observation. - const scannedCorrelationIds = new Set([...scannedProvenances].map(computeCorrelationId)); - let nativeReleases = null; - const unprovenClaim = (claims) => { - for (const claim of claims) { - if (scannedCorrelationIds.has(correlationRecoveryBase(claim.correlationId))) continue; - nativeReleases ??= fetchNativeReleases(gateway); - const why = unprovenNativeClaim(nativeReleases, claim); - if (why !== null) return why; - } - return null; - }; - const unprovenReason = (why) => ( - `${why}; output-tag order against that claim cannot be proven, so nothing was dispatched; ` - + 'a maintainer must resolve the claim or establish a new governed bridge build identity' - ); - - // A fresh pipeline would take the next free output tag, above any newer - // native's pipeline, and the publication barrier would then publish this - // older native first under the higher tag. - if (candidateSelection.matched.length === 0) { - if (newerNativeScanned) { - return new OrchestrationPlan({ - action: OrchestrationAction.SUPERSEDED, - reason: ( - `${provenance.nativeReleaseTag} has no pipeline for this build identity ` - + 'and a newer stable native release is part of this scan; only the ' - + 'newest scanned native release starts a new build identity' - ), - provenance, - correlationId, - }); - } - const newerClaims = newerNativeClaims(); - const unproven = unprovenClaim(newerClaims); - if (unproven !== null) { - return new OrchestrationPlan({ - action: OrchestrationAction.BLOCKED, reason: unprovenReason(unproven), provenance, correlationId, - }); - } - const [newerClaim] = newerClaims; - if (newerClaim !== undefined) { - const newer = correlationNativeIdentity(newerClaim.correlationId).nativeReleaseTag; - return new OrchestrationPlan({ - action: OrchestrationAction.SUPERSEDED, - reason: ( - `${provenance.nativeReleaseTag} has no pipeline for this build identity ` - + `and the pipeline of newer native release ${newer} claims ${newerClaim.releaseTag}; ` - + 'a fresh pipeline would take a higher output tag and publish first, ' - + `making ${newerClaim.releaseTag} an ordering rollback, so ${newer} supersedes it` - ), - provenance, - correlationId, - }); - } - } - - let freshBinding = null; - if (persisted === null) { - const target = selectNextReleaseTarget(releases.map((release) => pyStr(pyGet(release, 'tag_name'))), { - upstreamTag: provenance.upstreamTag, - taken: new Set([...assetTagNames, ...stillClaimedTags(), ...reservedReleaseTags]), - }); - freshBinding = new PipelineBinding({ - bridgeSourceSha: provenance.bridgeSourceSha, - releaseTag: target.releaseTag, - releaseRebuild: target.releaseRebuild, - }); - } - - if (candidateSelection.inFlightRunId !== null) { - return planPipeline({ - provenance, - correlationId, - observation: new PipelineObservation({ - binding: persisted, - freshBinding, - candidateInFlightRunId: candidateSelection.inFlightRunId, - }), - }); - } - - if (candidateSelection.succeededRunId === null && candidateSelection.unsuccessful.length > 0) { - const failedIds = candidateSelection.unsuccessful.map((record) => record.runId).join(', '); - if (persisted === null) throw new ContractError('failed candidate runs have no exact persisted binding'); - return new OrchestrationPlan({ - action: OrchestrationAction.BLOCKED, - reason: ( - `candidate run(s) ${failedIds} failed for this exact provenance; ` - + 'automatic candidate retries are disabled to prevent unbounded ' - + 'daily duplicates; a maintainer must diagnose the failure and ' - + 'explicitly dispatch one deliberate new first-attempt run with the ' - + 'same exact binding, or establish new provenance' - ), - provenance, - correlationId, - releaseTarget: persisted.releaseTarget, - }); - } - - if (candidateSelection.succeededRunId === null) { - const plan = planPipeline({ provenance, correlationId, observation: new PipelineObservation({ freshBinding }) }); - return dispatch(plan, CANDIDATE_WORKFLOW_PATH); - } - - if (persisted === null) throw new ContractError('a successful candidate run advertises no parsable pipeline binding'); - const candidate = verifyCandidateRun(gateway, { - runId: candidateSelection.succeededRunId, - provenance, - correlationId, - binding: persisted, - defaultBranch, - workspace, - }); - - const satisfiedPlan = (qualificationRunId) => { - const aligned = latestAlignedRelease(releases, provenance); - if (aligned === null || claimedRebuildsOf(candidate.binding, stillClaimedTags()).length > 0) return null; - const [alignedRelease, alignedCorrelationId] = aligned; - const published = verifyPublishedRelease(gateway, { - release: alignedRelease, - provenance, - correlationId: alignedCorrelationId, - workspace, - }); - if (published.directory === null) throw new ContractError('verified release did not retain its downloaded bytes'); - if (!publicationBytesIdentical( - candidatePublicationDigests(candidate.directory), - candidatePublicationDigests(published.directory), - )) { - return null; - } - return planPipeline({ - provenance, - correlationId, - observation: new PipelineObservation({ - binding: candidate.binding, - candidateRunId: candidate.runId, - qualificationRunId, - satisfiedBy: published, - }), - }); - }; - - const releaseTag = candidate.binding.releaseTag; - const retired = (action, reason, { qualificationRunId, releaseTarget = candidate.binding.releaseTarget }) => ( - new OrchestrationPlan({ - action, reason, provenance, correlationId, releaseTarget, candidateRunId: candidate.runId, qualificationRunId, - }) - ); - - // Publication follows native order, so an older native whose output tag is - // above a newer native's would publish first and turn the newer - // native's lower tag into an ordering rollback. - const outOfOrderPlan = (qualificationRunId) => { - const below = newerNativeClaims().filter((claim) => claimsNotBelow(claim.releaseTag, [releaseTag]).length > 0); - if (below.length === 0) return null; - const unproven = unprovenClaim(below); - if (unproven !== null) return retired(OrchestrationAction.BLOCKED, unprovenReason(unproven), { qualificationRunId }); - const [claim] = below; - const newer = correlationNativeIdentity(claim.correlationId).nativeReleaseTag; - return retired(OrchestrationAction.SUPERSEDED, ( - `candidate ${candidate.runId} is bound to ${releaseTag}, above ${claim.releaseTag} ` - + `claimed by the pipeline of newer native release ${newer}; publishing ` - + `${provenance.nativeReleaseTag} first would make ${claim.releaseTag} an ordering ` - + `rollback, so ${newer} supersedes it and nothing further is dispatched` - ), { qualificationRunId }); - }; - - // Once a published release reaches this candidate's output tag, publishing it - // is an ordering rollback, so it is never retried. It is satisfied only by a - // proven byte-identical publication, and replaced by a recovery pipeline when - // its bytes are proven to differ. - const overtakenPlan = (qualificationRunId) => { - const head = newestPublishedStableRelease(releases); - if (head === null) return null; - const headTag = pyStr(pyGet(head, 'tag_name')); - if (claimsNotBelow(releaseTag, [headTag]).length === 0) return null; - const overtaken = ( - `published release ${headTag} is at or above output tag ${releaseTag} of ` - + `candidate ${candidate.runId}, so publishing it would be an ordering rollback` - ); - // The next recovery generation of this provenance: a new correlation, so a - // fresh output tag and new first-attempt candidate, qualification and - // publication. - const recover = (why) => { - const recovered = observeAndPlan(gateway, { ...options, recovery: recovery + 1 }, evidence); - if (recovered instanceof StaleObservation) return recovered; - return withReason(recovered, ( - `${overtaken}; ${why}; recovery pipeline ${recovered.correlationId}: ${recovered.reason}` - )); - }; - // A published later generation settles this one, whatever its bytes. - const laterGenerations = Array.from( - { length: Math.max(0, MAX_RECOVERY_GENERATIONS - recovery) }, - (_, index) => correlationMarker(computeCorrelationId(provenance, { recovery: recovery + 1 + index })), - ); - if (releases.some((release) => { - const body = pyGet(release, 'body'); - return typeof body === 'string' && laterGenerations.some((marker) => body.includes(marker)); - })) { - return recover('a later recovery generation of this provenance is published'); - } - const satisfied = satisfiedPlan(qualificationRunId); - if (satisfied !== null) return satisfied; - // Blocked, it would hold the publication barrier and starve the newer - // native release that supersedes it anyway. - if (newerNativeScanned) { - return retired(OrchestrationAction.SUPERSEDED, ( - `${overtaken}; a newer stable native release is part of this scan and ` - + 'supersedes it, so nothing further is dispatched' - ), { qualificationRunId }); - } - const blocked = (why) => retired(OrchestrationAction.BLOCKED, ( - `${overtaken}; ${why}; nothing was dispatched` - ), { qualificationRunId }); - const body = pyGet(head, 'body'); - const [publisher, publisherCorrelationId] = (typeof body !== 'string' ? undefined : [...scannedProvenances] - .filter((other) => other.nativeReleaseTag !== provenance.nativeReleaseTag) - .flatMap((other) => Array.from( - { length: MAX_RECOVERY_GENERATIONS + 1 }, - (_, generation) => [other, computeCorrelationId(other, { recovery: generation })], - )) - .find(([, otherCorrelationId]) => body.includes(correlationMarker(otherCorrelationId)))) ?? []; - if (publisher === undefined) { - return blocked( - `${headTag} is not the publication of another native release in this scan, so its bytes cannot be compared; ` - + 'a scan that includes the native release it records compares them', - ); - } - const published = verifyPublishedRelease(gateway, { - release: head, - provenance: publisher, - correlationId: publisherCorrelationId, - workspace, - }); - if (published.directory === null) throw new ContractError('verified release did not retain its downloaded bytes'); - if (!publicationBytesIdentical( - candidatePublicationDigests(candidate.directory), - candidatePublicationDigests(published.directory), - )) { - if (recovery >= MAX_RECOVERY_GENERATIONS) { - return blocked( - `its publication files differ from ${headTag}, and recovery generation ${recovery} is the last automatic one; ` - + 'a maintainer must diagnose why output tags keep being overtaken', - ); - } - return recover(`its publication files differ from ${headTag}`); - } - return retired(OrchestrationAction.SATISFIED_BY_IDENTICAL_RELEASE, ( - `${overtaken}; its publication files other than manifest.json are byte-identical ` - + `to the immutable release ${headTag} (id ${pyStr(published.releaseId)}, published ` - + `${published.publishedAt}), which records native ${publisher.nativeReleaseTag}; ` - + 'nothing further is dispatched' - ), { qualificationRunId, releaseTarget: published.releaseTarget }); - }; - - const qualificationRuns = observedRuns(QUALIFICATION_WORKFLOW_FILE, QUALIFICATION_WORKFLOW_PATH); - const expectedQualificationName = qualificationRunName(correlationId, candidate.runId); - const qualificationSelection = selectPipelineRuns(qualificationRuns, { - label: 'qualification', - matcher: (name) => name === expectedQualificationName, - defaultBranch, - }); - if ( - qualificationSelection.succeededRunId === null - && qualificationSelection.inFlightRunId === null - && qualificationSelection.unsuccessful.length > 0 - ) { - const failedIds = qualificationSelection.unsuccessful.map((record) => record.runId).join(', '); - return new OrchestrationPlan({ - action: OrchestrationAction.BLOCKED, - reason: ( - `qualification run(s) ${failedIds} failed for candidate ` - + `${candidate.runId}; automatic qualification retries are disabled ` - + 'to prevent unbounded duplicates, so a maintainer must diagnose the ' - + 'heavy-gate failure before this candidate can advance' - ), - provenance, - correlationId, - releaseTarget: candidate.binding.releaseTarget, - candidateRunId: candidate.runId, - }); - } - - if (qualificationSelection.succeededRunId === null) { - if (qualificationSelection.inFlightRunId === null) { - const settled = outOfOrderPlan(null) ?? overtakenPlan(null) ?? satisfiedPlan(null); - if (settled !== null) return settled; - } - const plan = planPipeline({ - provenance, - correlationId, - observation: new PipelineObservation({ - binding: candidate.binding, - candidateRunId: candidate.runId, - qualificationInFlightRunId: qualificationSelection.inFlightRunId, - }), - }); - return dispatch(plan, QUALIFICATION_WORKFLOW_PATH); - } - - verifyQualificationRun(gateway, { - runId: qualificationSelection.succeededRunId, - candidate, - provenance, - correlationId, - defaultBranch, - workspace, - }); - - const publishRuns = observedRuns(PUBLISH_WORKFLOW_FILE, PUBLISH_WORKFLOW_PATH); - const expectedPublishName = publishRunName( - correlationId, - candidate.runId, - qualificationSelection.succeededRunId, - candidate.binding, - ); - const publishSelection = selectPipelineRuns(publishRuns, { - label: 'publication', - matcher: (name) => name === expectedPublishName, - defaultBranch, - }); - const publishIdle = publishSelection.inFlightRunId === null && publishSelection.succeededRunId === null; - if (publishIdle) { - const settled = outOfOrderPlan(qualificationSelection.succeededRunId) - ?? overtakenPlan(qualificationSelection.succeededRunId); - if (settled !== null) return settled; - } - if (!publicationAllowed) { - if (publishSelection.inFlightRunId !== null || publishSelection.succeededRunId !== null) { - return new OrchestrationPlan({ - action: OrchestrationAction.BLOCKED, - reason: ( - `publication for ${provenance.nativeReleaseTag} was started ` - + `before earlier native release ${publicationBarrierNativeTag} ` - + 'was immutably published' - ), - provenance, - correlationId, - releaseTarget: candidate.binding.releaseTarget, - candidateRunId: candidate.runId, - qualificationRunId: qualificationSelection.succeededRunId, - inFlightWorkflow: publishSelection.inFlightRunId !== null ? PUBLISH_WORKFLOW_FILE : null, - inFlightRunId: publishSelection.inFlightRunId, - }); - } - return new OrchestrationPlan({ - action: OrchestrationAction.WAITING_FOR_PRIOR_PUBLICATION, - reason: ( - `candidate ${candidate.runId} and qualification ` - + `${qualificationSelection.succeededRunId} are proven, but ` - + `earlier native release ${publicationBarrierNativeTag} must be ` - + 'immutably published first to preserve monotonic output ordering' - ), - provenance, - correlationId, - releaseTarget: candidate.binding.releaseTarget, - candidateRunId: candidate.runId, - qualificationRunId: qualificationSelection.succeededRunId, - }); - } - if (publishIdle) { - const satisfied = satisfiedPlan(qualificationSelection.succeededRunId); - if (satisfied !== null) return satisfied; - } - const plan = planPipeline({ - provenance, - correlationId, - observation: new PipelineObservation({ - binding: candidate.binding, - candidateRunId: candidate.runId, - qualificationRunId: qualificationSelection.succeededRunId, - publishInFlightRunId: publishSelection.inFlightRunId, - publishSucceededRunId: publishSelection.succeededRunId, - publishRetry: publishSelection.unsuccessful.length > 0, - }), - }); - return dispatch(plan, PUBLISH_WORKFLOW_PATH); -} - -// _execute_dispatch. Returns the plan with its dispatch record, a BLOCKED -// plan, or the guard's StaleObservation when a fresh correlation read -// contradicts the plan (advancePipeline re-plans on it; nothing is -// dispatched). `createdSince` bounds the guard's history read; it defaults to -// the native release's publication, before which no run of the correlation -// can exist. -export function executeDispatch(gateway, plan, { - defaultBranch, workflowPath, dryRun, createdSince = null, satisfiedCorrelationIds = [], -}) { - if (plan.dispatchWorkflow === null || plan.dispatchInputs === null) return plan; - const runName = plan.dispatchRunName; - if (runName === null) throw new ContractError('a dispatch plan must carry its deterministic run name'); - - const identity = gateway.dispatchIdentity(); - if (identity !== REPOSITORY_OWNER) { - return new OrchestrationPlan({ - action: OrchestrationAction.BLOCKED, - reason: ( - 'automatic dispatch identity could not be proven as ' - + `${pyRepr(REPOSITORY_OWNER)} (resolved ${pyRepr(identity ?? null)}); the owner-only actor ` - + 'gates stay in force and nothing was dispatched' - ), - provenance: plan.provenance, - correlationId: plan.correlationId, - releaseTarget: plan.releaseTarget, - candidateRunId: plan.candidateRunId, - qualificationRunId: plan.qualificationRunId, - }); - } - - const workflowFile = plan.dispatchWorkflow; - const governance = requireImmutableReleaseGovernance(gateway); - let inputs = { ...plan.dispatchInputs }; - const declared = WORKFLOW_DISPATCH_INPUTS.get(workflowFile) ?? []; - if (declared.includes('assets_immutable_releases_enabled')) { - // Carried from the live read above, so a governance regression can never - // be papered over by a literal the dispatcher writes about itself. - inputs.assets_immutable_releases_enabled = governance.enabled === true ? 'true' : 'false'; - } - if (declared.includes('publish_approved')) { - // In automatic mode the established solo-maintainer environment policy - // is the approval boundary. Never let the pure planner assert it. - requirePublicationEnvironment(gateway); - inputs.publish_approved = 'true'; - } - inputs = requireExactDispatchInputs(workflowFile, inputs); - - // Guard on the pipeline, not only on this plan's run name: a stale listing - // plus a fresh binding (another tag, rebuild or source commit) renders a - // different name, which the named-run check below would not catch. - const stale = guardStageCorrelation(gateway, plan, { - defaultBranch, - workflowPath, - createdSince: createdSince ?? plan.provenance.nativeReleasePublishedAt, - satisfiedCorrelationIds, - }); - if (stale !== null) return stale; - - if (dryRun) return withDispatchRecord(plan, { ref: defaultBranch, inputs }); - - const namedRun = () => findNamedRun(gateway, { - workflowFile, - workflowPath, - defaultBranch, - createdSince: plan.provenance.nativeReleasePublishedAt, - runName, - }); - if (namedRun() !== null) { - throw new ContractError(`a run named ${pyRepr(runName)} already exists; refusing to dispatch a duplicate`); - } - - gateway.dispatchWorkflow({ workflowFile, ref: defaultBranch, inputs }); - - let dispatchedRunId = null; - for (let attempt = 0; attempt < DISPATCH_READBACK_ATTEMPTS; attempt += 1) { - if (attempt) gateway.sleep(DISPATCH_READBACK_DELAY_SECONDS); - dispatchedRunId = namedRun(); - if (dispatchedRunId !== null) break; - } - if (dispatchedRunId === null) { - throw new ContractError( - `dispatched ${workflowFile} but no run named ${pyRepr(runName)} appeared; ` - + 'the pipeline state is unknown', - ); - } - return withDispatchRecord(plan, { ref: defaultBranch, inputs, dispatchedRunId }); -} - -// _with_dispatch_record: record the exact ref and inputs a dispatch used, or -// would have used. -export function withDispatchRecord(plan, { ref, inputs, dispatchedRunId = null }) { - return new OrchestrationPlan({ - action: plan.action, - reason: plan.reason, - provenance: plan.provenance, - correlationId: plan.correlationId, - releaseTarget: plan.releaseTarget, - candidateRunId: plan.candidateRunId, - qualificationRunId: plan.qualificationRunId, - dispatchWorkflow: plan.dispatchWorkflow, - dispatchRef: ref, - dispatchRunName: plan.dispatchRunName, - dispatchInputs: { ...inputs }, - dispatchedRunId, - }); -} diff --git a/scripts/release/orchestrator/model.mjs b/scripts/release/orchestrator/model.mjs deleted file mode 100644 index cce682c..0000000 --- a/scripts/release/orchestrator/model.mjs +++ /dev/null @@ -1,414 +0,0 @@ -// Shared identities and data types of the stable release orchestrator, the -// Node port of scripts/release_orchestrator_model.py. -// -// Workflow files and paths, the repository owner, the one pre-automation -// publication (v0.1.40) that keeps its historical manifest contract, the -// identity patterns and scalar validators every stage shares, and the -// provenance, binding, plan, and observation types the stage modules pass to -// each other. -// -// The Python types are frozen dataclasses. Their Node counterparts are frozen -// PyDataclass instances built from one options object with camelCase keys; -// `equals` is dataclass ==, and `pyHashKey` gives the value identity a Python -// set or dict key uses, which PyValueSet builds on. - -import { - BRIDGE_REPOSITORY, Channel, ContractError, NATIVE_REPOSITORY, parseReleaseTag, parseUpstreamTag, - requireRepository, requireSha256, validateReleaseIdentity, -} from '../contract.mjs'; -import { - PyException, PyFloat, isPyInt, pyEquals, pyRepr, pyTypeName, -} from '../json.mjs'; -import { pyFullmatch } from '../python_compat.mjs'; -import { CANDIDATE_WORKFLOW_PATH as RQ_CANDIDATE_WORKFLOW_PATH, QUALIFICATION_WORKFLOW_PATH as RQ_QUALIFICATION_WORKFLOW_PATH } from '../qualification.mjs'; - -export { ContractError }; - -export const CANDIDATE_WORKFLOW_FILE = 'bridge_candidate.yml'; -export const QUALIFICATION_WORKFLOW_FILE = 'bridge_qualification.yml'; -export const PUBLISH_WORKFLOW_FILE = 'publish_assets.yml'; -export const CANDIDATE_WORKFLOW_PATH = RQ_CANDIDATE_WORKFLOW_PATH; -export const QUALIFICATION_WORKFLOW_PATH = RQ_QUALIFICATION_WORKFLOW_PATH; -export const PUBLISH_WORKFLOW_PATH = `.github/workflows/${PUBLISH_WORKFLOW_FILE}`; -export const SUPPORTED_PIPELINE_WORKFLOW_PATHS = Object.freeze(new Set([ - CANDIDATE_WORKFLOW_PATH, - QUALIFICATION_WORKFLOW_PATH, - PUBLISH_WORKFLOW_PATH, -])); - -export const REPOSITORY_OWNER = BRIDGE_REPOSITORY.split('/')[0]; - -// v0.1.40 was immutably published for native/upstream v0.3.0 immediately -// before hosted automatic qualification replaced maintainer-run attestation. -// Its exact published bytes retain the historical gate vocabulary. This -// compatibility identity is deliberately narrower than the general candidate -// validator: every new candidate must still require hosted qualification. -export const LEGACY_MANUAL_QUALIFICATION_RELEASE_TAG = 'v0.1.40'; -export const LEGACY_MANUAL_QUALIFICATION_BRIDGE_COMMIT = '0bdc8286fd52b70da27f5b039e1b4278361da0be'; -export const LEGACY_MANUAL_QUALIFICATION_NATIVE_TAG = 'v0.3.0'; -export const LEGACY_MANUAL_QUALIFICATION_NATIVE_COMMIT = '28fca14873d4b4c531bef4425b261e2b911bdcce'; -export const LEGACY_MANUAL_QUALIFICATION_UPSTREAM_TAG = 'v0.3.0'; -export const LEGACY_MANUAL_QUALIFICATION_UPSTREAM_COMMIT = 'c1d0e7a004015f23bc0233470b747b596f29b264'; -export const LEGACY_MANUAL_QUALIFICATION_NATIVE_MANIFEST_SHA256 = '811fda999e70c3ad2716d1c196688dd38db62cf11a78044855ca94f71fabed45'; -export const LEGACY_MANUAL_QUALIFICATION_GATES = Object.freeze({ - state_persistence: 'passed', - multimodal: 'passed', - speech_to_text: 'required-local-attestation', - text_to_speech: 'required-local-attestation', -}); -export const LEGACY_MANUAL_UNPROVEN_CAPABILITIES = Object.freeze({ - real_device_intelligibility: 'unproven', - real_device_playback: 'unproven', - speaker_reference_fidelity: 'unproven', -}); - -// _COMMIT_RE and _UTC_TIMESTAMP_RE, anchored for pyFullmatch. -export const COMMIT_RE = /^[0-9a-f]{40}$/u; -export const UTC_TIMESTAMP_RE = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/u; - -// --- frozen dataclasses ---------------------------------------------------------- - -// The FIELDS default of a field without one. -export const REQUIRED = Symbol('required'); - -// "'a'", "'a' and 'b'", "'a', 'b', and 'c'", as CPython lists missing arguments. -function listArguments(names) { - const quoted = names.map((name) => `'${name}'`); - if (quoted.length === 1) return quoted[0]; - if (quoted.length === 2) return `${quoted[0]} and ${quoted[1]}`; - return `${quoted.slice(0, -1).join(', ')}, and ${quoted.at(-1)}`; -} - -// A frozen dataclass. A subclass declares static FIELDS as -// [camelName, snake_name, default] triples (default REQUIRED for none) and -// calls initFields(options) from its constructor, which assigns every field, -// raising the TypeError the dataclass __init__ raises for an unknown or a -// missing argument, before its own __post_init__ checks and freeze(). -export class PyDataclass { - initFields(options = {}) { - const fields = this.constructor.FIELDS; - const known = new Set(fields.map(([camel]) => camel)); - const init = `${this.constructor.name}.__init__()`; - for (const key of Object.keys(options)) { - if (!known.has(key)) throw new PyException('TypeError', `${init} got an unexpected keyword argument '${key}'`); - } - const missing = fields.filter(([camel, , fallback]) => fallback === REQUIRED && options[camel] === undefined); - if (missing.length > 0) { - const count = missing.length; - throw new PyException('TypeError', `${init} missing ${count} required positional argument${count === 1 ? '' : 's'}: ${listArguments(missing.map(([, snake]) => snake))}`); - } - for (const [camel, , fallback] of fields) { - const value = options[camel]; - this[camel] = value === undefined ? (fallback === REQUIRED ? null : fallback) : value; - } - } - - // dataclass __eq__: the same class and equal fields, in order. - equals(other) { - if (!(other instanceof PyDataclass) || other.constructor !== this.constructor) return false; - return this.constructor.FIELDS.every(([camel]) => pyValueEquals(this[camel], other[camel])); - } - - // The identity hash() and == give this value in a set or dict key. - get pyHashKey() { - return pyHashKey(this); - } -} - -// == of two field values: dataclasses and tuples by item, else Python ==. -export function pyValueEquals(left, right) { - if (left instanceof PyDataclass || right instanceof PyDataclass) { - return left instanceof PyDataclass && left.equals(right); - } - if (Array.isArray(left) && Array.isArray(right)) { - return left.length === right.length && left.every((item, index) => pyValueEquals(item, right[index])); - } - return pyEquals(left ?? null, right ?? null); -} - -// A string that is equal for two values exactly when Python's hash() and == -// agree they are the same set member: 1, 1.0 and True share one key. An array -// is a dataclass's tuple field and hashes by item; a dict is unhashable, as in -// Python. -export function pyHashKey(value) { - if (value === null || value === undefined) return 'N'; - if (value instanceof PyDataclass) { - return `D${value.constructor.name}(${value.constructor.FIELDS.map(([camel]) => pyHashKey(value[camel])).join(',')})`; - } - if (typeof value === 'boolean') return `i${value ? 1 : 0}`; - if (isPyInt(value)) return `i${BigInt(value)}`; - if (value instanceof PyFloat || typeof value === 'number') { - const number = value instanceof PyFloat ? value.value : value; - return Number.isInteger(number) ? `i${BigInt(number)}` : `f${number}`; - } - if (typeof value === 'string') return `s${JSON.stringify(value)}`; - if (Array.isArray(value)) return `t(${value.map(pyHashKey).join(',')})`; - throw new PyException('TypeError', `unhashable type: '${pyTypeName(value)}'`); -} - -// A Python set of dataclasses or scalars: membership by pyHashKey, iteration -// in insertion order (the first equal member added is the one kept). -export class PyValueSet { - constructor(values = []) { - this.members = new Map(); - for (const value of values) this.add(value); - } - - add(value) { - const key = pyHashKey(value); - if (!this.members.has(key)) this.members.set(key, value); - return this; - } - - has(value) { - return this.members.has(pyHashKey(value)); - } - - get size() { - return this.members.size; - } - - [Symbol.iterator]() { - return this.members.values(); - } -} - -// --- provenance ------------------------------------------------------------------ - -const PROVENANCE_COMMIT_FIELDS = [ - ['bridgeSourceSha', 'bridge_source_sha'], - ['bridgeBuildSha', 'bridge_build_sha'], - ['upstreamCommit', 'upstream_commit'], - ['nativeCommit', 'native_commit'], -]; - -// Exact identity of the native release this pipeline is aligned to. -export class NativeProvenance extends PyDataclass { - static FIELDS = Object.freeze([ - ['bridgeSourceSha', 'bridge_source_sha', REQUIRED], - ['bridgeBuildSha', 'bridge_build_sha', REQUIRED], - ['upstreamTag', 'upstream_tag', REQUIRED], - ['upstreamCommit', 'upstream_commit', REQUIRED], - ['nativeRepo', 'native_repo', REQUIRED], - ['nativeReleaseTag', 'native_release_tag', REQUIRED], - ['nativeCommit', 'native_commit', REQUIRED], - ['nativeManifestSha256', 'native_manifest_sha256', REQUIRED], - ['nativeReleasePublishedAt', 'native_release_published_at', REQUIRED], - ]); - - constructor(options) { - super(); - this.initFields(options); - for (const [camel, snake] of PROVENANCE_COMMIT_FIELDS) { - const value = this[camel]; - if (typeof value !== 'string' || COMMIT_RE.exec(value) === null) { - throw new ContractError(`${snake} must be a lowercase full 40-character commit SHA`); - } - } - requireSha256(this.nativeManifestSha256, 'native_manifest_sha256'); - requireRepository(this.nativeRepo, 'native_repo'); - if (this.nativeRepo !== NATIVE_REPOSITORY) throw new ContractError(`native_repo must be exactly ${NATIVE_REPOSITORY}`); - if (typeof this.nativeReleasePublishedAt !== 'string' || UTC_TIMESTAMP_RE.exec(this.nativeReleasePublishedAt) === null) { - throw new ContractError('native_release_published_at must use YYYY-MM-DDTHH:MM:SSZ'); - } - const upstream = parseUpstreamTag(this.upstreamTag); - const native = parseReleaseTag(this.nativeReleaseTag, { allowLegacy: true }); - if (native.channel !== upstream.channel) { - throw new ContractError(`native release ${pyRepr(this.nativeReleaseTag)} and upstream tag ${pyRepr(this.upstreamTag)} are on different channels`); - } - Object.freeze(this); - } - - get channel() { - return parseUpstreamTag(this.upstreamTag).channel; - } -} - -// _published_manifest_compatibility: the one immutable pre-automation -// manifest contract as [gates, capabilities], if applicable, else null. -export function publishedManifestCompatibility({ tag, provenance }) { - if ( - tag === LEGACY_MANUAL_QUALIFICATION_RELEASE_TAG - && provenance.bridgeBuildSha === LEGACY_MANUAL_QUALIFICATION_BRIDGE_COMMIT - && provenance.nativeReleaseTag === LEGACY_MANUAL_QUALIFICATION_NATIVE_TAG - && provenance.nativeCommit === LEGACY_MANUAL_QUALIFICATION_NATIVE_COMMIT - && provenance.upstreamTag === LEGACY_MANUAL_QUALIFICATION_UPSTREAM_TAG - && provenance.upstreamCommit === LEGACY_MANUAL_QUALIFICATION_UPSTREAM_COMMIT - && provenance.nativeManifestSha256 === LEGACY_MANUAL_QUALIFICATION_NATIVE_MANIFEST_SHA256 - ) { - return [LEGACY_MANUAL_QUALIFICATION_GATES, LEGACY_MANUAL_UNPROVEN_CAPABILITIES]; - } - return null; -} - -// Only the stable channel is orchestrated; everything else is scan-only. -export function requireStableProvenance(provenance) { - if (provenance.channel !== Channel.STABLE) { - throw new ContractError( - 'the release orchestrator only advances the stable channel, but this ' - + `provenance is ${provenance.channel} ` - + `(${provenance.nativeReleaseTag}@${provenance.upstreamTag})`, - ); - } - return provenance; -} - -export class ReleaseTarget extends PyDataclass { - static FIELDS = Object.freeze([ - ['releaseTag', 'release_tag', REQUIRED], - ['releaseRebuild', 'release_rebuild', REQUIRED], - ]); - - constructor(options) { - super(); - this.initFields(options); - Object.freeze(this); - } -} - -// The exact source and output identity one pipeline attempt is pinned to. -export class PipelineBinding extends PyDataclass { - static FIELDS = Object.freeze([ - ['bridgeSourceSha', 'bridge_source_sha', REQUIRED], - ['releaseTag', 'release_tag', REQUIRED], - ['releaseRebuild', 'release_rebuild', REQUIRED], - ]); - - constructor(options) { - super(); - this.initFields(options); - if (pyFullmatch(COMMIT_RE, this.bridgeSourceSha) === null) { - throw new ContractError('bridge_source_sha must be a lowercase full 40-character commit SHA'); - } - validateReleaseIdentity(this.releaseTag, this.releaseRebuild, 'v0.0.0'); - Object.freeze(this); - } - - get releaseTarget() { - return new ReleaseTarget({ releaseTag: this.releaseTag, releaseRebuild: this.releaseRebuild }); - } -} - -// `directory` is the path string of the verified release download, or null. -export class PublishedRelease extends PyDataclass { - static FIELDS = Object.freeze([ - ['releaseId', 'release_id', REQUIRED], - ['releaseTarget', 'release_target', REQUIRED], - ['binding', 'binding', REQUIRED], - ['publishedAt', 'published_at', REQUIRED], - ['directory', 'directory', null], - ]); - - constructor(options) { - super(); - this.initFields(options); - Object.freeze(this); - } -} - -// OrchestrationAction(str, Enum): each member is its value. -export const OrchestrationAction = Object.freeze({ - NOOP: 'noop', - SUPERSEDED: 'superseded', - SATISFIED_BY_IDENTICAL_RELEASE: 'satisfied_by_identical_release', - IN_FLIGHT: 'in_flight', - WAITING_FOR_PRIOR_PUBLICATION: 'waiting_for_prior_publication', - DISPATCH_CANDIDATE: 'dispatch_candidate', - DISPATCH_QUALIFICATION: 'dispatch_qualification', - DISPATCH_PUBLISH: 'dispatch_publish', - BLOCKED: 'blocked', -}); - -export class OrchestrationPlan extends PyDataclass { - static FIELDS = Object.freeze([ - ['action', 'action', REQUIRED], - ['reason', 'reason', REQUIRED], - ['provenance', 'provenance', REQUIRED], - ['correlationId', 'correlation_id', REQUIRED], - ['releaseTarget', 'release_target', null], - ['candidateRunId', 'candidate_run_id', null], - ['qualificationRunId', 'qualification_run_id', null], - ['inFlightWorkflow', 'in_flight_workflow', null], - ['inFlightRunId', 'in_flight_run_id', null], - ['dispatchWorkflow', 'dispatch_workflow', null], - ['dispatchRef', 'dispatch_ref', null], - ['dispatchRunName', 'dispatch_run_name', null], - ['dispatchInputs', 'dispatch_inputs', null], - ['dispatchedRunId', 'dispatched_run_id', null], - ]); - - constructor(options) { - super(); - this.initFields(options); - Object.freeze(this); - } - - // to_dict(): the plan record, keys in Python's insertion order. - toDict() { - const target = this.releaseTarget; - const provenance = this.provenance; - return { - schema_version: 1, - action: this.action, - reason: this.reason, - correlation_id: this.correlationId, - provenance: { - bridge_source_sha: provenance.bridgeSourceSha, - bridge_build_sha: provenance.bridgeBuildSha, - upstream_tag: provenance.upstreamTag, - upstream_commit: provenance.upstreamCommit, - native_repo: provenance.nativeRepo, - native_release_tag: provenance.nativeReleaseTag, - native_commit: provenance.nativeCommit, - native_manifest_sha256: provenance.nativeManifestSha256, - native_release_published_at: provenance.nativeReleasePublishedAt, - }, - release_tag: target ? target.releaseTag : null, - release_rebuild: target ? target.releaseRebuild : null, - candidate_run_id: this.candidateRunId, - qualification_run_id: this.qualificationRunId, - in_flight_workflow: this.inFlightWorkflow, - in_flight_run_id: this.inFlightRunId, - dispatch_workflow: this.dispatchWorkflow, - dispatch_ref: this.dispatchRef, - dispatch_run_name: this.dispatchRunName, - dispatch_inputs: this.dispatchInputs, - dispatched_run_id: this.dispatchedRunId, - }; - } -} - -// Everything already proven about this correlation's pipeline. -export class PipelineObservation extends PyDataclass { - static FIELDS = Object.freeze([ - ['published', 'published', null], - ['binding', 'binding', null], - ['freshBinding', 'fresh_binding', null], - ['candidateInFlightRunId', 'candidate_in_flight_run_id', null], - ['candidateRunId', 'candidate_run_id', null], - ['satisfiedBy', 'satisfied_by', null], - ['qualificationInFlightRunId', 'qualification_in_flight_run_id', null], - ['qualificationRunId', 'qualification_run_id', null], - ['publishInFlightRunId', 'publish_in_flight_run_id', null], - ['publishSucceededRunId', 'publish_succeeded_run_id', null], - ['publishRetry', 'publish_retry', false], - ]); - - constructor(options = {}) { - super(); - this.initFields(options); - Object.freeze(this); - } -} - -// _require_str. -export function requireStr(value, label) { - if (typeof value !== 'string' || !value) throw new ContractError(`${label} must be a non-empty string`); - return value; -} - -// _require_positive_int. -export function requirePositiveInt(value, label) { - if (!isPyInt(value) || BigInt(value) <= 0n) throw new ContractError(`${label} must be a positive integer`); - return value; -} diff --git a/scripts/release/orchestrator/native.mjs b/scripts/release/orchestrator/native.mjs deleted file mode 100644 index f1b3705..0000000 --- a/scripts/release/orchestrator/native.mjs +++ /dev/null @@ -1,156 +0,0 @@ -// Native release scanning for the stable release orchestrator, the Node port -// of scripts/release_orchestrator_native.py. -// -// Extracts exact provenance from a native assets.json on either channel, -// selects every published stable native release after the automation baseline, -// and orders native release tags. - -import { createHash } from 'node:crypto'; - -import { - Channel, ContractError, NATIVE_REPOSITORY, parseReleaseTag, resolveNativeManifest, strictJsonLoads, -} from '../contract.mjs'; -import { - compareCodePoints, isDict, isPyException, pyDecodeUtf8, pyGet, pyIsFile, pyIsSymlink, pyPath, - pyReadBytes, pyRepr, -} from '../json.mjs'; -import { pyCompareIntTuples, pyFullmatch } from '../python_compat.mjs'; -import { NativeProvenance, UTC_TIMESTAMP_RE } from './model.mjs'; - -// v0.2.0-1 was published as the first verified immutable automatic-publication -// baseline (Web assets v0.1.39). Older native releases belong to the historical -// pre-automation series and must not be silently rebuilt by backlog scans. -export const STABLE_AUTOMATION_BASELINE_NATIVE_TAG = 'v0.2.0-1'; -export const STABLE_AUTOMATION_BASELINE_PUBLISHED_AT = '2026-08-25T08:57:12Z'; - -// _CHANNELS: each channel's value, mapped to itself. -export const CHANNELS = Object.freeze(new Map(Object.values(Channel).map((channel) => [channel, channel]))); - -export function requireChannel(value) { - const channel = typeof value === 'string' ? CHANNELS.get(value) : undefined; - if (channel === undefined) { - throw new ContractError(`unsupported release channel ${pyRepr(value)}; expected one of ${[...CHANNELS.keys()].sort(compareCodePoints).join(', ')}`); - } - return channel; -} - -// Extract exact provenance from a native assets.json on either channel. -// -// Development scans are supported so a maintainer can inspect a bNNNN native -// release, but only the stable channel is ever orchestrated; see -// requireStableProvenance. `manifestPath` is a path string. -export function scanNativeProvenance({ - manifestPath, - nativeReleaseTag, - bridgeSourceSha, - bridgeBuildSha, - channel, - nativeReleasePublishedAt, -}) { - const requested = requireChannel(channel); - const path = pyPath(String(manifestPath)); - if (!pyIsFile(path) || pyIsSymlink(path)) throw new ContractError(`native manifest is not a regular file: ${path}`); - const raw = pyReadBytes(path); - let text; - try { - text = pyDecodeUtf8(raw); - } catch (error) { - if (isPyException(error, 'UnicodeDecodeError')) { - throw new ContractError(`native manifest is not UTF-8: ${error.message}`, { cause: error }); - } - throw error; - } - const manifest = strictJsonLoads(text, 'native manifest'); - if (!isDict(manifest)) throw new ContractError('native manifest root must be a JSON object'); - const identity = resolveNativeManifest(manifest, nativeReleaseTag); - const provenance = new NativeProvenance({ - bridgeSourceSha, - bridgeBuildSha, - upstreamTag: identity.upstreamTag, - upstreamCommit: identity.upstreamCommit, - nativeRepo: NATIVE_REPOSITORY, - nativeReleaseTag: identity.releaseTag, - nativeCommit: identity.nativeCommit, - nativeManifestSha256: createHash('sha256').update(raw).digest('hex'), - nativeReleasePublishedAt, - }); - if (provenance.channel !== requested) { - throw new ContractError( - `a ${requested} scan resolved the ${provenance.channel} native ` - + `release ${pyRepr(identity.releaseTag)} (${identity.upstreamTag})`, - ); - } - return provenance; -} - -// (*version.version_parts, version.rebuild). -function releaseOrder(version) { - return [...version.versionParts, version.rebuild]; -} - -// Return every published stable native tag after the migration baseline. -// -// The publication timestamp defines which releases belong to automatic -// orchestration; the tag ordering independently rejects a post-baseline -// rollback. This lets a later release receive its candidate while an earlier -// release advances through qualification, without backfilling the mutable -// historical release series. -export function selectStableNativeBacklog(releases, { - baselineTag = STABLE_AUTOMATION_BASELINE_NATIVE_TAG, - baselinePublishedAt = STABLE_AUTOMATION_BASELINE_PUBLISHED_AT, -} = {}) { - if (pyFullmatch(UTC_TIMESTAMP_RE, baselinePublishedAt) === null) { - throw new ContractError('stable automation baseline timestamp is not canonical'); - } - const baseline = parseReleaseTag(baselineTag); - if (baseline.channel !== Channel.STABLE) throw new ContractError('stable automation baseline tag is not stable'); - const baselineOrder = releaseOrder(baseline); - - const selected = []; - const seenTags = new Set(); - releases.forEach((release, index) => { - if (!isDict(release)) throw new ContractError(`native release listing entry ${index} is not an object`); - const draft = pyGet(release, 'draft'); - if (typeof draft !== 'boolean') throw new ContractError(`native release listing entry ${index} has no boolean draft`); - if (draft) return; - const tag = pyGet(release, 'tag_name'); - if (typeof tag !== 'string' || !tag) throw new ContractError(`native release listing entry ${index} has no tag_name`); - let version; - try { - version = parseReleaseTag(tag); - } catch (error) { - // Development and historical/foreign tag forms are not stable automatic- - // publication candidates. - if (error instanceof ContractError) return; - throw error; - } - if (version.channel !== Channel.STABLE) return; - const prerelease = pyGet(release, 'prerelease'); - if (typeof prerelease !== 'boolean' || prerelease !== version.githubPrerelease) { - throw new ContractError(`stable native release ${pyRepr(tag)} has inconsistent prerelease state`); - } - const publishedAt = pyGet(release, 'published_at'); - if (typeof publishedAt !== 'string' || UTC_TIMESTAMP_RE.exec(publishedAt) === null) { - throw new ContractError(`stable native release ${pyRepr(tag)} has no canonical published_at`); - } - if (compareCodePoints(publishedAt, baselinePublishedAt) <= 0) return; - const order = releaseOrder(version); - if (pyCompareIntTuples(order, baselineOrder) <= 0) { - throw new ContractError(`post-baseline stable native release ${pyRepr(tag)} does not advance ${pyRepr(baselineTag)}`); - } - if (seenTags.has(tag)) throw new ContractError(`stable native release ${pyRepr(tag)} is duplicated`); - seenTags.add(tag); - selected.push([publishedAt, order, tag]); - }); - - // sorted() over (published_at, order, tag) tuples. - selected.sort((left, right) => compareCodePoints(left[0], right[0]) - || pyCompareIntTuples(left[1], right[1]) - || compareCodePoints(left[2], right[2])); - return selected.map(([, , tag]) => tag); -} - -// _native_release_order. -export function nativeReleaseOrder(nativeReleaseTag) { - return releaseOrder(parseReleaseTag(nativeReleaseTag)); -} diff --git a/scripts/release/orchestrator/planner.mjs b/scripts/release/orchestrator/planner.mjs deleted file mode 100644 index 9b5b733..0000000 --- a/scripts/release/orchestrator/planner.mjs +++ /dev/null @@ -1,279 +0,0 @@ -// Pure state machine of the stable release orchestrator, the Node port of -// scripts/release_orchestrator_planner.py. -// -// Holds each stage workflow's exact declared dispatch inputs and decides the -// single next transition from already-proven evidence, without live access. - -import { ASSETS_REPOSITORY, ContractError, requireCorrelationId } from '../contract.mjs'; -import { - compareCodePoints, isDict, pyKeys, pyRepr, pyStr, -} from '../json.mjs'; -import { - CANDIDATE_WORKFLOW_FILE, - OrchestrationAction, - OrchestrationPlan, - PUBLISH_WORKFLOW_FILE, - QUALIFICATION_WORKFLOW_FILE, -} from './model.mjs'; -import { candidateRunName, publishRunName, qualificationRunName } from './run_names.mjs'; - -export const CANDIDATE_DISPATCH_INPUTS = Object.freeze([ - 'orchestrator_correlation_id', - 'bridge_source_sha', - 'upstream_tag', - 'upstream_commit', - 'native_release_tag', - 'native_manifest_sha256', - 'release_tag', - 'release_rebuild', - 'assets_immutable_releases_enabled', -]); - -export const QUALIFICATION_DISPATCH_INPUTS = Object.freeze([ - 'orchestrator_correlation_id', - 'candidate_run_id', -]); - -export const PUBLISH_DISPATCH_INPUTS = Object.freeze([ - 'orchestrator_correlation_id', - 'bridge_source_sha', - 'upstream_tag', - 'upstream_commit', - 'native_release_tag', - 'native_manifest_sha256', - 'release_tag', - 'release_rebuild', - 'assets_repo', - 'publish_approved', - 'candidate_run_id', - 'qualification_run_id', -]); - -// Every dispatch is checked against these before it leaves the process. GitHub -// rejects a dispatch whose inputs drift from the workflow's declared set, and it -// does so only at dispatch time, in production; the contract suite asserts these -// lists are byte-for-byte the workflows' declared inputs. -export const WORKFLOW_DISPATCH_INPUTS = Object.freeze(new Map([ - [CANDIDATE_WORKFLOW_FILE, CANDIDATE_DISPATCH_INPUTS], - [QUALIFICATION_WORKFLOW_FILE, QUALIFICATION_DISPATCH_INPUTS], - [PUBLISH_WORKFLOW_FILE, PUBLISH_DISPATCH_INPUTS], -])); - -// Refuse to dispatch anything but the workflow's exact declared input set. -// `inputs` is a dict (plain object or Map); the result is a new object in the -// declared order. -export function requireExactDispatchInputs(workflowFile, inputs) { - const expected = WORKFLOW_DISPATCH_INPUTS.get(workflowFile); - if (expected === undefined) throw new ContractError(`${workflowFile} is not a workflow this orchestrator may dispatch`); - const actual = new Set(inputs instanceof Map || isDict(inputs) ? pyKeys(inputs) : inputs); - const wanted = new Set(expected); - const sameSet = actual.size === wanted.size && [...actual].every((name) => wanted.has(name)); - if (!sameSet) { - const missing = [...wanted].filter((name) => !actual.has(name)).sort(compareCodePoints); - const unexpected = [...actual].filter((name) => !wanted.has(name)).sort(compareCodePoints); - throw new ContractError( - `dispatch inputs for ${workflowFile} are not the exact declared set ` - + `(missing: ${pyRepr(missing)}, unexpected: ${pyRepr(unexpected)})`, - ); - } - const ordered = {}; - for (const name of expected) { - const value = inputs instanceof Map ? inputs.get(name) : inputs[name]; - if (typeof value !== 'string' || !value) { - throw new ContractError(`dispatch input ${pyRepr(name)} for ${workflowFile} must be a non-empty string, got ${pyRepr(value)}`); - } - ordered[name] = value; - } - return ordered; -} - -// _dispatch_inputs_for_candidate: every candidate input the planner can know -// without live proof. -// -// assets_immutable_releases_enabled is deliberately absent: it is an assertion -// about the assets repository, so it is filled in from the live governance -// read taken immediately before dispatch, never from a constant. -export function dispatchInputsForCandidate(provenance, correlationId, binding) { - return { - orchestrator_correlation_id: correlationId, - bridge_source_sha: binding.bridgeSourceSha, - upstream_tag: provenance.upstreamTag, - upstream_commit: provenance.upstreamCommit, - native_release_tag: provenance.nativeReleaseTag, - native_manifest_sha256: provenance.nativeManifestSha256, - release_tag: binding.releaseTag, - release_rebuild: pyStr(binding.releaseRebuild), - }; -} - -// _dispatch_inputs_for_qualification. -export function dispatchInputsForQualification(correlationId, candidateRunId) { - return { - orchestrator_correlation_id: correlationId, - candidate_run_id: candidateRunId, - }; -} - -// _dispatch_inputs_for_publish. -export function dispatchInputsForPublish(provenance, correlationId, binding, candidateRunId, qualificationRunId) { - return { - orchestrator_correlation_id: correlationId, - bridge_source_sha: binding.bridgeSourceSha, - upstream_tag: provenance.upstreamTag, - upstream_commit: provenance.upstreamCommit, - native_release_tag: provenance.nativeReleaseTag, - native_manifest_sha256: provenance.nativeManifestSha256, - release_tag: binding.releaseTag, - release_rebuild: pyStr(binding.releaseRebuild), - assets_repo: ASSETS_REPOSITORY, - candidate_run_id: candidateRunId, - qualification_run_id: qualificationRunId, - }; -} - -// Decide the single next transition from already-proven evidence. -export function planPipeline({ provenance, correlationId, observation }) { - requireCorrelationId(correlationId); - - if (observation.published !== null) { - const published = observation.published; - return new OrchestrationPlan({ - action: OrchestrationAction.NOOP, - reason: ( - 'this provenance is already published as the immutable release ' - + `${published.releaseTarget.releaseTag} (id ${pyStr(published.releaseId)}, ` - + `published ${published.publishedAt})` - ), - provenance, - correlationId, - releaseTarget: published.releaseTarget, - }); - } - - if (observation.candidateInFlightRunId !== null) { - return new OrchestrationPlan({ - action: OrchestrationAction.IN_FLIGHT, - reason: `candidate run ${observation.candidateInFlightRunId} is still running; refusing to dispatch a duplicate`, - provenance, - correlationId, - releaseTarget: observation.binding ? observation.binding.releaseTarget : null, - inFlightWorkflow: CANDIDATE_WORKFLOW_FILE, - inFlightRunId: observation.candidateInFlightRunId, - }); - } - - if (observation.candidateRunId === null) { - const binding = observation.freshBinding; - if (binding === null) throw new ContractError('no pipeline binding is available for candidate dispatch'); - return new OrchestrationPlan({ - action: OrchestrationAction.DISPATCH_CANDIDATE, - reason: `new stable native provenance requires exactly one candidate build for ${binding.releaseTag}`, - provenance, - correlationId, - releaseTarget: binding.releaseTarget, - dispatchWorkflow: CANDIDATE_WORKFLOW_FILE, - dispatchRunName: candidateRunName(correlationId, binding), - dispatchInputs: dispatchInputsForCandidate(provenance, correlationId, binding), - }); - } - - const binding = observation.binding; - if (binding === null) throw new ContractError('a successful candidate run must carry its proven pipeline binding'); - - if (observation.satisfiedBy !== null) { - const satisfiedBy = observation.satisfiedBy; - return new OrchestrationPlan({ - action: OrchestrationAction.SATISFIED_BY_IDENTICAL_RELEASE, - reason: ( - `candidate run ${observation.candidateRunId} bound to ` - + `${binding.releaseTag} (rebuild ${pyStr(binding.releaseRebuild)}) has ` - + 'publication files other than manifest.json byte-identical to the ' - + `immutable release ${satisfiedBy.releaseTarget.releaseTag} (id ` - + `${pyStr(satisfiedBy.releaseId)}, published ${satisfiedBy.publishedAt}) ` - + `for native ${provenance.nativeReleaseTag}; nothing further is ` - + 'dispatched' - ), - provenance, - correlationId, - releaseTarget: satisfiedBy.releaseTarget, - candidateRunId: observation.candidateRunId, - qualificationRunId: observation.qualificationRunId, - }); - } - - if (observation.qualificationInFlightRunId !== null) { - return new OrchestrationPlan({ - action: OrchestrationAction.IN_FLIGHT, - reason: `qualification run ${observation.qualificationInFlightRunId} is still running`, - provenance, - correlationId, - releaseTarget: binding.releaseTarget, - candidateRunId: observation.candidateRunId, - inFlightWorkflow: QUALIFICATION_WORKFLOW_FILE, - inFlightRunId: observation.qualificationInFlightRunId, - }); - } - - if (observation.qualificationRunId === null) { - return new OrchestrationPlan({ - action: OrchestrationAction.DISPATCH_QUALIFICATION, - reason: ( - `candidate run ${observation.candidateRunId} is built and proven; ` - + 'dispatching exactly one hosted qualification run for the heavy ' - + 'real-model gates' - ), - provenance, - correlationId, - releaseTarget: binding.releaseTarget, - candidateRunId: observation.candidateRunId, - dispatchWorkflow: QUALIFICATION_WORKFLOW_FILE, - dispatchRunName: qualificationRunName(correlationId, observation.candidateRunId), - dispatchInputs: dispatchInputsForQualification(correlationId, observation.candidateRunId), - }); - } - - if (observation.publishInFlightRunId !== null) { - return new OrchestrationPlan({ - action: OrchestrationAction.IN_FLIGHT, - reason: `publication run ${observation.publishInFlightRunId} is still running`, - provenance, - correlationId, - releaseTarget: binding.releaseTarget, - candidateRunId: observation.candidateRunId, - qualificationRunId: observation.qualificationRunId, - inFlightWorkflow: PUBLISH_WORKFLOW_FILE, - inFlightRunId: observation.publishInFlightRunId, - }); - } - - if (observation.publishSucceededRunId !== null) { - throw new ContractError( - `publication run ${observation.publishSucceededRunId} succeeded but no ` - + `immutable release for correlation ${pyRepr(correlationId)} could be verified`, - ); - } - - return new OrchestrationPlan({ - action: OrchestrationAction.DISPATCH_PUBLISH, - reason: ( - `${observation.publishRetry ? 'retrying publication of' : 'publishing'}` - + ` ${binding.releaseTag} from the proven candidate ` - + `${observation.candidateRunId} and qualification ` - + `${observation.qualificationRunId}` - ), - provenance, - correlationId, - releaseTarget: binding.releaseTarget, - candidateRunId: observation.candidateRunId, - qualificationRunId: observation.qualificationRunId, - dispatchWorkflow: PUBLISH_WORKFLOW_FILE, - dispatchRunName: publishRunName(correlationId, observation.candidateRunId, observation.qualificationRunId, binding), - dispatchInputs: dispatchInputsForPublish( - provenance, - correlationId, - binding, - observation.candidateRunId, - observation.qualificationRunId, - ), - }); -} diff --git a/scripts/release/orchestrator/release_tags.mjs b/scripts/release/orchestrator/release_tags.mjs deleted file mode 100644 index ada46c5..0000000 --- a/scripts/release/orchestrator/release_tags.mjs +++ /dev/null @@ -1,198 +0,0 @@ -// Bridge-asset output tag selection for the stable release orchestrator, the -// Node port of scripts/release_orchestrator_release_tags.py. -// -// Picks the next free npm-shaped vMAJOR.MINOR.PATCH tag independently of the -// upstream tag, and determines which output tags candidate runs still claim -// across correlations. - -import { - Channel, ContractError, parseReleaseTag, parseUpstreamTag, validateNewReleaseIdentity, -} from '../contract.mjs'; -import { compareCodePoints } from '../json.mjs'; -import { pyCompareIntTuples, pyStrSplit } from '../python_compat.mjs'; -import { ReleaseTarget } from './model.mjs'; -import { CANDIDATE_RUN_NAME_RE } from './run_names.mjs'; - -// Bridge assets version independently of llama.cpp: an upstream v0.2.0 -// candidate publishes as the next bridge patch (v0.1.39 -> v0.1.40), never as -// v0.2.0. -export const INITIAL_STABLE_RELEASE_TAG = 'v0.1.0'; - -// The version parts of a patch bump, as Python ints: bigint when the number -// leaves the safe-integer range. -function increment(value) { - if (typeof value === 'bigint') return value + 1n; - return Number.isSafeInteger(value + 1) ? value + 1 : BigInt(value) + 1n; -} - -// Pick the next free bridge-asset tag, independently of the upstream tag. -// -// `releaseTags` are the assets repository's existing releases. `taken` are -// tags still claimed by a pipeline (any iterable). Both set the version floor, -// so a new tag is never lower than a published tag or a live claim. A claim -// that is released without publication stops counting, so its version may be -// taken again or stay unused. The result is always an unsuffixed -// vMAJOR.MINOR.PATCH with rebuild 0: npm orders -N as a prerelease of the same -// version, so a collision moves to the next free patch version instead of a -// rebuild suffix. -export function selectNextReleaseTarget(releaseTags, { upstreamTag, taken = [] }) { - parseUpstreamTag(upstreamTag); - const published = new Set([...releaseTags].filter((tag) => typeof tag === 'string')); - const claimed = new Set([...published, ...[...taken].filter((tag) => typeof tag === 'string')]); - const versions = []; - for (const tag of claimed) { - let version; - try { - version = parseReleaseTag(tag, { allowLegacy: true }); - } catch (error) { - if (error instanceof ContractError) continue; - throw error; - } - if (version.channel === Channel.STABLE) versions.push(version); - } - let major; - let minor; - let patch; - if (versions.length > 0) { - // max() with a tuple key keeps the first of equal keys; equal keys share - // version parts, so the result does not depend on set order. - let highest = versions[0]; - for (const version of versions.slice(1)) { - if (pyCompareIntTuples([...version.versionParts, version.rebuild], [...highest.versionParts, highest.rebuild]) > 0) { - highest = version; - } - } - [major, minor, patch] = highest.versionParts; - patch = increment(patch); - } else { - [major, minor, patch] = parseReleaseTag(INITIAL_STABLE_RELEASE_TAG).versionParts; - } - - let tag = `v${major}.${minor}.${patch}`; - while (claimed.has(tag)) { - patch = increment(patch); - tag = `v${major}.${minor}.${patch}`; - } - validateNewReleaseIdentity(tag, 0, upstreamTag); - return new ReleaseTarget({ releaseTag: tag, releaseRebuild: 0 }); -} - -// _claimed_rebuilds_of: claimed tags that can publish only after `binding` -// publishes rebuild 0, sorted. -export function claimedRebuildsOf(binding, claimed) { - if (BigInt(binding.releaseRebuild) !== 0n) return []; - const version = parseReleaseTag(binding.releaseTag); - const dependents = []; - for (const tag of claimed) { - let claim; - try { - claim = parseReleaseTag(tag); - } catch (error) { - if (error instanceof ContractError) continue; - throw error; - } - if (pyCompareIntTuples(claim.versionParts, version.versionParts) === 0 && BigInt(claim.rebuild) > 0n) { - dependents.push(tag); - } - } - return dependents.sort(compareCodePoints); -} - -const CORRELATION_BUILD_RE = /-(?:build|r[1-9][0-9]?)-(?[0-9a-f]{16})$/u; - -// _run_correlation_id. -function runCorrelationId(record) { - const fields = pyStrSplit(record.runName); - return fields.length > 1 ? fields[1] : null; -} - -// _names_other_build. -function namesOtherBuild(correlationId, bridgeBuildSha) { - const build = CORRELATION_BUILD_RE.exec(correlationId); - return build !== null && build.groups.build !== bridgeBuildSha.slice(0, 16); -} - -export function hasOtherBuildClaim(candidateRuns, { bridgeBuildSha }) { - for (const record of candidateRuns) { - const match = CANDIDATE_RUN_NAME_RE.exec(record.runName); - if (match !== null && namesOtherBuild(match.groups.correlation_id, bridgeBuildSha)) return true; - } - return false; -} - -// Output tags still claimed by candidate runs, across correlations, as a Set. -// -// A claim is dropped when its correlation names a build identity other than -// `bridgeBuildSha` and no candidate, qualification or publication run of that -// correlation is in flight, or when this scan already found that correlation -// satisfied by an identical release. Scans advance only the current build -// identity and a satisfied correlation publishes nothing, so nothing -// dispatches for such a correlation again. -export function claimedReleaseTags(candidateRuns, options) { - return new Set(releaseTagClaims(candidateRuns, options).map(({ releaseTag }) => releaseTag)); -} - -// The claims behind claimedReleaseTags, as { correlationId, releaseTag }. -export function releaseTagClaims(candidateRuns, { - bridgeBuildSha, downstreamRuns = [], satisfiedCorrelationIds = [], -}) { - const inFlight = new Set( - [...candidateRuns, ...downstreamRuns].filter((record) => record.inFlight).map(runCorrelationId), - ); - const satisfied = new Set(satisfiedCorrelationIds); - const claims = []; - for (const record of candidateRuns) { - const match = CANDIDATE_RUN_NAME_RE.exec(record.runName); - if (match === null) continue; - const correlationId = match.groups.correlation_id; - if (satisfied.has(correlationId) || (namesOtherBuild(correlationId, bridgeBuildSha) && !inFlight.has(correlationId))) { - continue; - } - claims.push({ correlationId, releaseTag: match.groups.release_tag }); - } - return claims; -} - -// The claimed stable tags at or above `releaseTag`, sorted. -// -// selectNextReleaseTarget picks a tag above every published tag and live -// claim it was shown, so a claim this returns for a freshly selected tag is -// one the selection never saw: the tag itself, a rebuild of it, or a higher -// version, each of which would have moved the selection. -export function claimsNotBelow(releaseTag, claimed) { - const target = parseReleaseTag(releaseTag); - const floor = [...target.versionParts, target.rebuild]; - const conflicting = []; - for (const tag of claimed) { - let claim; - try { - claim = parseReleaseTag(tag, { allowLegacy: true }); - } catch (error) { - if (error instanceof ContractError) continue; - throw error; - } - if (claim.channel === Channel.STABLE && pyCompareIntTuples([...claim.versionParts, claim.rebuild], floor) >= 0) { - conflicting.push(tag); - } - } - return conflicting.sort(compareCodePoints); -} - -// The correlation a candidate run name claims an output tag for, and that -// tag, or null for a name that is not a candidate run name. -export function candidateTagClaim(runName) { - const match = typeof runName === 'string' ? CANDIDATE_RUN_NAME_RE.exec(runName) : null; - return match === null ? null : { correlationId: match.groups.correlation_id, releaseTag: match.groups.release_tag }; -} - -// namesOtherBuild, for the dispatch guard's claim check. -export function claimNamesOtherBuild(correlationId, bridgeBuildSha) { - return namesOtherBuild(correlationId, bridgeBuildSha); -} - -// The correlation a stage run name belongs to (its second field), or null. -export function runNameCorrelationId(runName) { - if (typeof runName !== 'string') return null; - const fields = pyStrSplit(runName); - return fields.length > 1 ? fields[1] : null; -} diff --git a/scripts/release/orchestrator/run_names.mjs b/scripts/release/orchestrator/run_names.mjs deleted file mode 100644 index 6f33f28..0000000 --- a/scripts/release/orchestrator/run_names.mjs +++ /dev/null @@ -1,221 +0,0 @@ -// Deterministic correlation IDs and workflow run names, the Node port of -// scripts/release_orchestrator_run_names.py. -// -// The live actions/runs API never echoes a run's dispatch inputs, so each -// stage workflow renders a run-name from its own exact inputs. These helpers -// derive the correlation ID, build each stage's run name, and parse a run name -// back into its pipeline binding, failing closed when a name claims this -// correlation but does not parse exactly. - -import { ContractError, requireCorrelationId } from '../contract.mjs'; -import { PY_NON_SPACE_CLASS, pyIntFromString, pyRepr, pyStr } from '../json.mjs'; -import { pyFullmatch, pyStrSplit } from '../python_compat.mjs'; -import { - LEGACY_MANUAL_QUALIFICATION_BRIDGE_COMMIT, - LEGACY_MANUAL_QUALIFICATION_NATIVE_COMMIT, - LEGACY_MANUAL_QUALIFICATION_NATIVE_MANIFEST_SHA256, - LEGACY_MANUAL_QUALIFICATION_NATIVE_TAG, - LEGACY_MANUAL_QUALIFICATION_UPSTREAM_COMMIT, - LEGACY_MANUAL_QUALIFICATION_UPSTREAM_TAG, - PipelineBinding, -} from './model.mjs'; - -// Run-name has no documented length guarantee. Keep this internal identity well -// below common database/display limits and accept only printable ASCII emitted -// by the exact input validators. A truncated title can therefore never be -// mistaken for a pipeline identity. -export const MAX_RUN_NAME_CHARACTERS = 200; - -const RUN_ID_RE = /^[1-9][0-9]*$/u; -const RUN_NAME_RE = /^[A-Za-z0-9 ._:/-]+$/u; -const REBUILD_RE = /^(?:0|[1-9][0-9]*)$/u; - -// Python's \S+ (a run of characters str.isspace() rejects). -const S = `${PY_NON_SPACE_CLASS}+`; - -// _CANDIDATE_RUN_NAME_RE and _PUBLISH_RUN_NAME_RE, anchored for fullmatch. -export const CANDIDATE_RUN_NAME_RE = new RegExp( - `^bridge-candidate (?${S}) source:(?${S})` - + ` tag:(?${S}) rebuild:(?${S})$`, - 'u', -); -export const PUBLISH_RUN_NAME_RE = new RegExp( - `^publish-assets (?${S}) candidate:(?${S})` - + ` qualification:(?${S}) source:(?${S})` - + ` tag:(?${S}) rebuild:(?${S})$`, - 'u', -); - -// len(str): code points. -function pyLen(text) { - let length = 0; - for (const _char of text) length += 1; - return length; -} - -// Derive one stable pipeline identity from native and governed build inputs. -// -// The checkout source may advance while a pipeline is in flight because -// workflows, tests, or docs changed. The governed build identity does not, so -// those changes cannot orphan a candidate. A runtime/build change deliberately -// creates a new correlation and therefore requires a new qualified candidate. -// -// The one immutable pre-automation publication keeps its historical -// correlation only while the governed build identity is still its exact -// bridge commit. -// -// `recovery` N > 0 names the Nth replacement pipeline of a candidate whose -// output tag a published release overtook: the same provenance under a new -// correlation, so it binds a fresh output tag and proves every stage again -// without touching the stranded pipeline's runs, artifacts or attestation. -// It replaces `-build-` with the shorter `-rN-`, so its publish run name is -// no longer than the stranded pipeline's, which is already close to -// MAX_RUN_NAME_CHARACTERS. -export function computeCorrelationId(provenance, { recovery = 0 } = {}) { - if (!Number.isSafeInteger(recovery) || recovery < 0 || recovery > 99) { - throw new ContractError('recovery generation must be an integer from 0 to 99'); - } - if ( - recovery === 0 - && provenance.bridgeBuildSha === LEGACY_MANUAL_QUALIFICATION_BRIDGE_COMMIT - && provenance.nativeReleaseTag === LEGACY_MANUAL_QUALIFICATION_NATIVE_TAG - && provenance.nativeCommit === LEGACY_MANUAL_QUALIFICATION_NATIVE_COMMIT - && provenance.upstreamTag === LEGACY_MANUAL_QUALIFICATION_UPSTREAM_TAG - && provenance.upstreamCommit === LEGACY_MANUAL_QUALIFICATION_UPSTREAM_COMMIT - && provenance.nativeManifestSha256 === LEGACY_MANUAL_QUALIFICATION_NATIVE_MANIFEST_SHA256 - ) { - return requireCorrelationId(`auto-stable-${provenance.nativeReleaseTag}-${provenance.nativeManifestSha256.slice(0, 16)}`); - } - const raw = `auto-stable-${provenance.nativeReleaseTag}` - + `-${provenance.nativeManifestSha256.slice(0, 16)}` - + `${recovery === 0 ? '-build-' : `-r${recovery}-`}${provenance.bridgeBuildSha.slice(0, 16)}`; - return requireCorrelationId(raw); -} - -const RECOVERY_BUILD_RE = /-r[1-9][0-9]?-(?[0-9a-f]{16})$/u; - -// The correlation computeCorrelationId gave the stranded pipeline that -// `correlationId` recovers, or `correlationId` itself. -export function correlationRecoveryBase(correlationId) { - return correlationId.replace(RECOVERY_BUILD_RE, (_, build) => `-build-${build}`); -} - -const CORRELATION_NATIVE_RE = new RegExp( - '^auto-stable-(?v(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)(?:-[1-9][0-9]*)?)' - + '-(?[0-9a-f]{16})(?:-(?:build|r[1-9][0-9]?)-[0-9a-f]{16})?$', - 'u', -); - -// The stable native release tag and native manifest digest prefix that -// computeCorrelationId put in `correlationId`, as { nativeReleaseTag, -// nativeManifestPrefix }, or null for an identity it did not compute. -export function correlationNativeIdentity(correlationId) { - const match = typeof correlationId === 'string' ? CORRELATION_NATIVE_RE.exec(correlationId) : null; - return match === null ? null : { nativeReleaseTag: match.groups.native, nativeManifestPrefix: match.groups.manifest }; -} - -export function candidateRunName(correlationId, binding) { - requireCorrelationId(correlationId); - return requireRunName( - `bridge-candidate ${correlationId}` - + ` source:${binding.bridgeSourceSha}` - + ` tag:${binding.releaseTag}` - + ` rebuild:${pyStr(binding.releaseRebuild)}`, - ); -} - -export function qualificationRunName(correlationId, candidateRunId) { - requireCorrelationId(correlationId); - if (pyFullmatch(RUN_ID_RE, candidateRunId) === null) throw new ContractError('candidate_run_id must be a positive integer'); - return requireRunName(`bridge-qualification ${correlationId} candidate:${candidateRunId}`); -} - -export function publishRunName(correlationId, candidateRunId, qualificationRunId, binding) { - requireCorrelationId(correlationId); - for (const [label, value] of [['candidate_run_id', candidateRunId], ['qualification_run_id', qualificationRunId]]) { - if (pyFullmatch(RUN_ID_RE, value) === null) throw new ContractError(`${label} must be a positive integer`); - } - return requireRunName( - `publish-assets ${correlationId}` - + ` candidate:${candidateRunId}` - + ` qualification:${qualificationRunId}` - + ` source:${binding.bridgeSourceSha}` - + ` tag:${binding.releaseTag}` - + ` rebuild:${pyStr(binding.releaseRebuild)}`, - ); -} - -// _require_run_name. -export function requireRunName(value) { - if ( - typeof value !== 'string' - || !value - || pyLen(value) > MAX_RUN_NAME_CHARACTERS - || RUN_NAME_RE.exec(value) === null - ) { - throw new ContractError('workflow run name exceeds the conservative length/safe-character contract'); - } - return value; -} - -// _parse_binding_fields. -function parseBindingFields(groups, label) { - const rebuild = groups.release_rebuild; - if (REBUILD_RE.exec(rebuild) === null) throw new ContractError(`${label} encodes a malformed rebuild counter`); - try { - return new PipelineBinding({ - bridgeSourceSha: groups.bridge_source_sha, - releaseTag: groups.release_tag, - releaseRebuild: pyIntFromString(rebuild), - }); - } catch (error) { - if (error instanceof ContractError) { - throw new ContractError(`${label} encodes an invalid pipeline binding: ${error.message}`, { cause: error }); - } - throw error; - } -} - -// Recover the binding a candidate run was dispatched with, or null. -// -// null means the name belongs to a different correlation. A name that claims -// this correlation but cannot be parsed exactly fails closed. -export function parseCandidateRunName(runName, correlationId) { - requireCorrelationId(correlationId); - if (typeof runName !== 'string') return null; - const fields = pyStrSplit(runName); - const claimsCorrelation = fields.length >= 2 && fields[0] === 'bridge-candidate' && fields[1] === correlationId; - if (pyLen(runName) > MAX_RUN_NAME_CHARACTERS || RUN_NAME_RE.exec(runName) === null) { - if (claimsCorrelation) { - throw new ContractError(`candidate run name claiming correlation ${pyRepr(correlationId)} exceeds the length or character contract`); - } - return null; - } - const match = CANDIDATE_RUN_NAME_RE.exec(runName); - if (match !== null) { - if (match.groups.correlation_id !== correlationId) return null; - return parseBindingFields(match.groups, 'candidate run name'); - } - if (claimsCorrelation) { - throw new ContractError(`candidate run name claiming correlation ${pyRepr(correlationId)} is malformed`); - } - return null; -} - -// [candidateRunId, qualificationRunId, binding], or null. -export function parsePublishRunName(runName, correlationId) { - requireCorrelationId(correlationId); - if (typeof runName !== 'string' || pyLen(runName) > MAX_RUN_NAME_CHARACTERS || RUN_NAME_RE.exec(runName) === null) { - return null; - } - const match = PUBLISH_RUN_NAME_RE.exec(runName); - if (match === null || match.groups.correlation_id !== correlationId) return null; - for (const label of ['candidate_run_id', 'qualification_run_id']) { - if (RUN_ID_RE.exec(match.groups[label]) === null) throw new ContractError(`publish run name has a malformed ${label}`); - } - return [ - match.groups.candidate_run_id, - match.groups.qualification_run_id, - parseBindingFields(match.groups, 'publish run name'), - ]; -} diff --git a/scripts/release/orchestrator/stage_proofs.mjs b/scripts/release/orchestrator/stage_proofs.mjs deleted file mode 100644 index c1aec80..0000000 --- a/scripts/release/orchestrator/stage_proofs.mjs +++ /dev/null @@ -1,247 +0,0 @@ -// Candidate and qualification run proofs for the stable release orchestrator, -// the Node port of scripts/release_orchestrator_stage_proofs.py. -// -// Binds a candidate manifest to its exact provenance and correlation, and -// proves a candidate or qualification run from its run record, reachability -// from the default branch, its unique artifact, and the hosted qualification -// attestation. -// -// Paths are strings with pathlib's str() form: the workspace is normalized -// with pyPath and every child is joined with pyJoinPath, so error text that -// names a path matches Python's. - -import fs from 'node:fs'; - -import { extractFlatArtifactArchive } from '../archive.mjs'; -import { - ASSETS_REPOSITORY, BRIDGE_REPOSITORY, ContractError, NATIVE_REPOSITORY, requireSha256, validateReleaseIdentity, -} from '../contract.mjs'; -import { - PyException, isDict, isPyInt, pyEquals, pyGet, pyIsFile, pyItems, pyJoinPath, pyKeys, pyOSError, pyPath, pyRepr, -} from '../json.mjs'; -import { ARTIFACTS } from '../manifest.mjs'; -import { pyFullmatch } from '../python_compat.mjs'; -import { - ATTESTATION_ARTIFACT_NAME, CANDIDATE_ARTIFACT_NAME, loadAttestationFile, loadCandidate, validateArtifactInventory, - validateWorkflowRun, verifyAttestation, -} from '../qualification.mjs'; -import { - CANDIDATE_WORKFLOW_PATH, COMMIT_RE, PipelineBinding, PyDataclass, QUALIFICATION_WORKFLOW_PATH, REQUIRED, requireStr, -} from './model.mjs'; - -// isinstance(value, int) and not isinstance(value, bool) and value >= 0. -function isNonNegativeInt(value) { - return isPyInt(value) && BigInt(value) >= 0n; -} - -// Fail closed unless a manifest binds this exact provenance and correlation. -export function validateCandidateManifest(manifest, { - provenance, - correlationId, - expectedReleaseTag = null, - expectedBridgeSourceSha = null, - expectedRunId = null, -}) { - const expected = [ - ['assets_repository', ASSETS_REPOSITORY], - ['bridge_repository', BRIDGE_REPOSITORY], - // `expected_bridge_source_sha or manifest.get("bridge_commit")`. - ['bridge_commit', expectedBridgeSourceSha || pyGet(manifest, 'bridge_commit')], - ['upstream_repository', 'ggml-org/llama.cpp'], - ['upstream_tag', provenance.upstreamTag], - ['upstream_commit', provenance.upstreamCommit], - ['native_repository', NATIVE_REPOSITORY], - ['native_release_tag', provenance.nativeReleaseTag], - ['native_manifest_sha256', provenance.nativeManifestSha256], - ['native_commit', provenance.nativeCommit], - ['orchestrator_correlation_id', correlationId], - ]; - for (const [key, value] of expected) { - if (!pyEquals(pyGet(manifest, key), value)) { - throw new ContractError(`candidate manifest ${key} is ${pyRepr(pyGet(manifest, key))}, expected ${pyRepr(value)}`); - } - } - const bridgeCommit = requireStr(pyGet(manifest, 'bridge_commit'), 'manifest bridge_commit'); - if (pyFullmatch(COMMIT_RE, bridgeCommit) === null) throw new ContractError('manifest bridge_commit must be a 40-hex commit SHA'); - if (expectedRunId !== null && !pyEquals(pyGet(manifest, 'github_run_id'), expectedRunId)) { - throw new ContractError( - `candidate manifest github_run_id is ${pyRepr(pyGet(manifest, 'github_run_id'))}, ` - + `expected ${pyRepr(expectedRunId)}`, - ); - } - const releaseTag = requireStr(pyGet(manifest, 'release_tag'), 'manifest release_tag'); - const rebuild = pyGet(manifest, 'release_rebuild'); - if (!isNonNegativeInt(rebuild)) throw new ContractError('manifest release_rebuild must be a non-negative integer'); - if (expectedReleaseTag !== null && releaseTag !== expectedReleaseTag) { - throw new ContractError(`candidate manifest release_tag is ${pyRepr(releaseTag)}, expected ${pyRepr(expectedReleaseTag)}`); - } - validateReleaseIdentity(releaseTag, rebuild, provenance.upstreamTag); - const artifacts = pyGet(manifest, 'artifacts'); - const artifactNames = isDict(artifacts) || artifacts instanceof Map ? pyKeys(artifacts) : null; - if ( - artifactNames === null - || new Set(artifactNames).size !== ARTIFACTS.length - || !ARTIFACTS.every((name) => artifactNames.includes(name)) - ) { - throw new ContractError('candidate manifest does not record exactly the artifact set'); - } - for (const [name, record] of pyItems(artifacts)) { - if (!isDict(record) && !(record instanceof Map)) throw new ContractError(`candidate manifest artifact ${pyRepr(name)} is malformed`); - requireSha256(requireStr(pyGet(record, 'sha256'), `${name} sha256`), `${name} sha256`); - if (!isNonNegativeInt(pyGet(record, 'size_bytes'))) { - throw new ContractError(`candidate manifest artifact ${pyRepr(name)} has an invalid size`); - } - } - return new PipelineBinding({ bridgeSourceSha: bridgeCommit, releaseTag, releaseRebuild: rebuild }); -} - -// `directory` is the path string of the extracted candidate artifact. -export class CandidateEvidence extends PyDataclass { - static FIELDS = Object.freeze([ - ['runId', 'run_id', REQUIRED], - ['artifactId', 'artifact_id', REQUIRED], - ['fingerprint', 'fingerprint', REQUIRED], - ['binding', 'binding', REQUIRED], - ['manifest', 'manifest', REQUIRED], - ['directory', 'directory', REQUIRED], - ]); - - constructor(options) { - super(); - this.initFields(options); - Object.freeze(this); - } -} - -// _require_reachable_from_main. -export function requireReachableFromMain(gateway, { - commit, defaultBranch, label, -}) { - const payload = gateway.apiJson(`repos/${BRIDGE_REPOSITORY}/compare/${commit}...${defaultBranch}`); - if (!isDict(payload)) throw new ContractError('compare response must be a JSON object'); - const status = pyGet(payload, 'status'); - if (!pyEquals(status, 'ahead') && !pyEquals(status, 'identical')) { - throw new ContractError(`${label} commit ${commit} is not reachable from ${defaultBranch} (compare status ${pyRepr(status)})`); - } -} - -// Path.write_bytes(data). -function writeBytes(target, data) { - try { - fs.writeFileSync(target, data); - } catch (error) { - throw pyOSError(error, target); - } -} - -// Path.mkdir(parents=True, exist_ok=True): an existing non-directory raises -// FileExistsError. -export function makeDirectories(target) { - try { - fs.mkdirSync(target, { recursive: true }); - } catch (error) { - const exception = pyOSError(error, target); - if (error?.code === 'EEXIST' && exception instanceof PyException) { - const replaced = new PyException('FileExistsError', exception.message); - Object.assign(replaced, { errno: exception.errno, code: exception.code, filename: exception.filename }); - throw replaced; - } - throw exception; - } -} - -// _download_run_artifact: [artifactId, destination]. -export function downloadRunArtifact(gateway, { - runId, artifactName, artifactType, workspace, -}) { - const inventory = gateway.apiJson(`repos/${BRIDGE_REPOSITORY}/actions/runs/${runId}/artifacts?per_page=100`); - const artifactId = validateArtifactInventory(inventory, { expectedRunId: runId, expectedName: artifactName }); - const root = pyPath(String(workspace)); - const archive = pyJoinPath(root, `${artifactType}-${runId}.zip`); - writeBytes(archive, gateway.downloadBytes( - `repos/${BRIDGE_REPOSITORY}/actions/artifacts/${artifactId}/zip`, - { accept: 'application/vnd.github+json' }, - )); - const destination = pyJoinPath(root, `${artifactType}-${runId}`); - makeDirectories(destination); - extractFlatArtifactArchive(archive, destination, { artifactType }); - return [artifactId, destination]; -} - -export function verifyCandidateRun(gateway, { - runId, provenance, correlationId, binding, defaultBranch, workspace, -}) { - const run = gateway.apiJson(`repos/${BRIDGE_REPOSITORY}/actions/runs/${runId}`); - const headSha = validateWorkflowRun(run, { - expectedRunId: runId, - expectedWorkflowPath: CANDIDATE_WORKFLOW_PATH, - expectedHeadBranch: defaultBranch, - expectedRunAttempt: 1, - }); - requireReachableFromMain(gateway, { commit: headSha, defaultBranch, label: 'candidate run head' }); - const [artifactId, directory] = downloadRunArtifact(gateway, { - runId, - artifactName: CANDIDATE_ARTIFACT_NAME, - artifactType: 'candidate', - workspace, - }); - const [manifest, fingerprint] = loadCandidate(directory); - const manifestBinding = validateCandidateManifest(manifest, { - provenance, - correlationId, - expectedReleaseTag: binding.releaseTag, - expectedBridgeSourceSha: binding.bridgeSourceSha, - expectedRunId: runId, - }); - if (!manifestBinding.equals(binding)) { - throw new ContractError('candidate manifest contradicts the binding its run name advertises'); - } - requireReachableFromMain(gateway, { commit: binding.bridgeSourceSha, defaultBranch, label: 'candidate bridge source' }); - return new CandidateEvidence({ - runId, artifactId, fingerprint, binding, manifest, directory, - }); -} - -export function verifyQualificationRun(gateway, { - runId, candidate, provenance, correlationId, defaultBranch, workspace, -}) { - const run = gateway.apiJson(`repos/${BRIDGE_REPOSITORY}/actions/runs/${runId}`); - const qualificationSourceSha = validateWorkflowRun(run, { - expectedRunId: runId, - expectedWorkflowPath: QUALIFICATION_WORKFLOW_PATH, - expectedHeadBranch: defaultBranch, - expectedRunAttempt: 1, - }); - requireReachableFromMain(gateway, { commit: qualificationSourceSha, defaultBranch, label: 'qualification workflow source' }); - const [, directory] = downloadRunArtifact(gateway, { - runId, - artifactName: ATTESTATION_ARTIFACT_NAME, - artifactType: 'attestation', - workspace, - }); - const attestationPath = pyJoinPath(directory, 'qualification-attestation.json'); - if (!pyIsFile(attestationPath)) throw new ContractError('attestation artifact does not contain the canonical payload'); - const attestation = loadAttestationFile(attestationPath); - const emscriptenVersion = requireStr(pyGet(candidate.manifest, 'emscripten_version'), 'candidate manifest emscripten_version'); - return verifyAttestation({ - attestation, - candidateDir: candidate.directory, - candidateFingerprint: candidate.fingerprint, - candidateRunId: candidate.runId, - candidateArtifactId: candidate.artifactId, - candidateRunAttempt: 1, - qualificationRunId: runId, - qualificationRunAttempt: 1, - qualificationSourceSha, - bridgeSourceSha: candidate.binding.bridgeSourceSha, - upstreamTag: provenance.upstreamTag, - upstreamCommit: provenance.upstreamCommit, - nativeReleaseTag: provenance.nativeReleaseTag, - nativeManifestSha256: provenance.nativeManifestSha256, - nativeCommit: provenance.nativeCommit, - emscriptenVersion, - releaseTag: candidate.binding.releaseTag, - releaseRebuild: candidate.binding.releaseRebuild, - orchestratorCorrelationId: correlationId, - }); -} diff --git a/scripts/release/orchestrator/transport.mjs b/scripts/release/orchestrator/transport.mjs deleted file mode 100644 index 76e90df..0000000 --- a/scripts/release/orchestrator/transport.mjs +++ /dev/null @@ -1,158 +0,0 @@ -// Live GitHub transport for the stable release orchestrator, the Node port of -// scripts/release_orchestrator_transport.py. -// -// The Gateway protocol is what every live proof and dispatch is written -// against; GhGateway implements it with the gh CLI, keeping read traffic and -// dispatch on distinct tokens. Every call is synchronous, as in Python. -// -// Gateway (duck-typed; tests/release/orchestrator/fixtures.mjs has a fake): -// apiJson(path, { paginate = false, privileged = false } = {}) -> JSON value -// downloadBytes(path, { accept, privileged = false }) -> Buffer -// dispatchIdentity() -> string | null -// releaseAttestation({ repository, releaseTag }) -> JSON value -// dispatchWorkflow({ workflowFile, ref, inputs }) -> undefined -// sleep(seconds) -> undefined -// utcNow() -> "YYYY-MM-DDTHH:MM:SSZ" - -import { spawnSync } from 'node:child_process'; -import process from 'node:process'; - -import { BRIDGE_REPOSITORY, ContractError, strictJsonLoads } from '../contract.mjs'; -import { - PyException, isDict, pyDecodeUtf8, pyGet, pyJsonDumps, pyStrftimeUtc, pyStrip, -} from '../json.mjs'; -import { isOSError, osErrorString } from '../python_compat.mjs'; - -const GH_MAX_BUFFER = 1024 * 1024 * 1024; - -// bytes.decode("utf-8", "replace"). -function decodeReplace(bytes) { - return new TextDecoder('utf-8', { ignoreBOM: true }).decode(bytes); -} - -// The OSError subprocess.run raises when the executable cannot be started. -function spawnError(error, executable) { - if (!isOSError(error)) return error; - const classes = { ENOENT: 'FileNotFoundError', EACCES: 'PermissionError', EPERM: 'PermissionError' }; - const exception = new PyException(classes[error.code] ?? 'OSError', osErrorString(error, executable)); - exception.code = error.code; - return exception; -} - -// time.sleep(seconds), blocking the thread as Python does. -function sleepSeconds(seconds) { - if (!(seconds >= 0)) throw new PyException('ValueError', 'sleep length must be non-negative'); - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, seconds * 1000); -} - -// datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"). -export function utcNowText(now = new Date()) { - return pyStrftimeUtc({ - year: now.getUTCFullYear(), - month: now.getUTCMonth() + 1, - day: now.getUTCDate(), - hour: now.getUTCHours(), - minute: now.getUTCMinutes(), - second: now.getUTCSeconds(), - }); -} - -// gh-backed transport. Read traffic and dispatch use distinct tokens. -// -// `gh` (the executable), `env` (the environment the child inherits, read at -// call time; process.env by default), `clock` (a function returning the -// current Date) and `sleeper` are injectable for tests. -export class GhGateway { - constructor({ - readToken, dispatchToken, gh = 'gh', env = null, clock = () => new Date(), sleeper = sleepSeconds, - }) { - this.readToken = readToken; - this.dispatchToken = dispatchToken || null; - this.gh = gh; - this.env = env; - this.clock = clock; - this.sleeper = sleeper; - } - - // The child environment: a copy of the environment with GH_TOKEN set to - // `token` and GITHUB_TOKEN removed. - childEnv(token) { - const env = { ...(this.env ?? process.env) }; - env.GH_TOKEN = token; - delete env.GITHUB_TOKEN; - return env; - } - - // _run(args, privileged=..., binary=...): args[0] is "gh". Returns stdout. - run(args, { privileged }) { - const token = privileged ? this.dispatchToken : this.readToken; - if (!token) throw new ContractError('no credential is available for this GitHub request; refusing to continue'); - const executable = args[0] === 'gh' ? this.gh : args[0]; - const completed = spawnSync(executable, args.slice(1), { - env: this.childEnv(token), - stdio: ['inherit', 'pipe', 'pipe'], - maxBuffer: GH_MAX_BUFFER, - }); - if (completed.error) throw spawnError(completed.error, args[0]); - if (completed.status !== 0) { - const stderr = pyStrip(decodeReplace(completed.stderr)); - throw new ContractError(`gh ${args.slice(1).join(' ')} failed: ${stderr}`); - } - return completed.stdout; - } - - apiJson(path, { paginate = false, privileged = false } = {}) { - const args = ['gh', 'api']; - if (paginate) args.push('--paginate', '--slurp'); - args.push(path); - const stdout = this.run(args, { privileged }); - return strictJsonLoads(pyDecodeUtf8(stdout), `gh api ${path}`); - } - - downloadBytes(path, { accept, privileged = false }) { - return this.run(['gh', 'api', '-H', `Accept: ${accept}`, path], { privileged }); - } - - dispatchIdentity() { - if (!this.dispatchToken) return null; - let payload; - try { - payload = this.apiJson('user', { privileged: true }); - } catch (error) { - if (error instanceof ContractError) return null; - throw error; - } - const login = isDict(payload) ? pyGet(payload, 'login') : null; - return typeof login === 'string' && login ? login : null; - } - - releaseAttestation({ repository, releaseTag }) { - const stdout = this.run(['gh', 'release', 'verify', releaseTag, '--repo', repository, '--format', 'json'], { privileged: false }); - return strictJsonLoads(pyDecodeUtf8(stdout), `gh release verify ${repository}@${releaseTag}`); - } - - // The inputs go to `gh workflow run --json` on stdin as - // json.dumps(dict(inputs), sort_keys=True). - dispatchWorkflow({ workflowFile, ref, inputs }) { - if (!this.dispatchToken) throw new ContractError('workflow dispatch requires an orchestrator credential'); - const completed = spawnSync(this.gh, ['workflow', 'run', workflowFile, '--repo', BRIDGE_REPOSITORY, '--ref', ref, '--json'], { - input: Buffer.from(pyJsonDumps({ ...inputs }, { sortKeys: true }), 'utf8'), - env: this.childEnv(this.dispatchToken), - stdio: ['pipe', 'pipe', 'pipe'], - maxBuffer: GH_MAX_BUFFER, - }); - if (completed.error) throw spawnError(completed.error, 'gh'); - if (completed.status !== 0) { - const stderr = pyStrip(decodeReplace(completed.stderr)); - throw new ContractError(`dispatching ${workflowFile} failed: ${stderr}`); - } - } - - sleep(seconds) { - this.sleeper(seconds); - } - - utcNow() { - return utcNowText(this.clock()); - } -} diff --git a/scripts/release/orchestrator/workflow_runs.mjs b/scripts/release/orchestrator/workflow_runs.mjs deleted file mode 100644 index c397637..0000000 --- a/scripts/release/orchestrator/workflow_runs.mjs +++ /dev/null @@ -1,494 +0,0 @@ -// Workflow run inventory for the stable release orchestrator, the Node port of -// scripts/release_orchestrator_workflow_runs.py. -// -// Validates server-filtered actions/runs listings, recovers complete run -// history by paginating with stable counts and splitting searches at GitHub's -// 1,000-result cap into closed time windows, and selects at most one live and -// one successful first-attempt run per pipeline stage. - -import { BRIDGE_REPOSITORY, ContractError } from '../contract.mjs'; -import { - isDict, isPyInt, pyEquals, pyGet, pyRepr, pyUrlencode, -} from '../json.mjs'; -import { PyUtcDatetime, pyFullmatch } from '../python_compat.mjs'; -import { - COMMIT_RE, PyDataclass, PyValueSet, REPOSITORY_OWNER, REQUIRED, SUPPORTED_PIPELINE_WORKFLOW_PATHS, UTC_TIMESTAMP_RE, - requirePositiveInt, requireStr, -} from './model.mjs'; -import { parseCandidateRunName } from './run_names.mjs'; - -export const MAX_FILTERED_WORKFLOW_RUNS = 100; -export const MAX_GITHUB_FILTERED_SEARCH_RESULTS = 1000; -export const RUN_HISTORY_WINDOW_DAYS = 30; - -const IN_FLIGHT_STATUSES = Object.freeze(new Set([ - 'queued', 'in_progress', 'waiting', 'requested', 'pending', 'action_required', -])); - -// Python int comparisons of a response count (a number or a bigint). -const big = (value) => BigInt(value); - -export class RunRecord extends PyDataclass { - static FIELDS = Object.freeze([ - ['runId', 'run_id', REQUIRED], - ['runName', 'run_name', REQUIRED], - ['status', 'status', REQUIRED], - ['conclusion', 'conclusion', REQUIRED], - ['headBranch', 'head_branch', REQUIRED], - ['headSha', 'head_sha', REQUIRED], - ['runAttempt', 'run_attempt', REQUIRED], - ]); - - constructor(options) { - super(); - this.initFields(options); - Object.freeze(this); - } - - get inFlight() { - return this.status !== 'completed'; - } - - get succeeded() { - return this.status === 'completed' && this.conclusion === 'success'; - } -} - -// Validate one server-filtered response proven complete on one page. -// -// The live driver uses the lower-level page parser to paginate stable counts -// and split searches at GitHub's cap. This strict helper remains useful for -// callers and tests that expect exactly one complete response. -export function parseWorkflowRuns(payload, { workflowPath, defaultBranch }) { - const [totalCount, records] = parseWorkflowRunsResponse(payload, { workflowPath }); - if (big(totalCount) > BigInt(MAX_FILTERED_WORKFLOW_RUNS) || BigInt(records.length) !== big(totalCount)) { - throw new ContractError( - `filtered workflow run listing for ${workflowPath} is truncated or ` - + `ambiguous: ${records.length} records for total_count=${totalCount}`, - ); - } - requireStr(defaultBranch, 'default branch'); - return records; -} - -// _parse_workflow_runs_response: [totalCount, records]. -export function parseWorkflowRunsResponse(payload, { workflowPath }) { - if (!isDict(payload)) throw new ContractError('workflow runs response must be an object containing workflow_runs'); - const totalCount = pyGet(payload, 'total_count'); - if (!isPyInt(totalCount) || big(totalCount) < 0n) { - throw new ContractError('workflow runs total_count must be a non-negative integer'); - } - const runs = pyGet(payload, 'workflow_runs'); - if (!Array.isArray(runs)) throw new ContractError('workflow runs response is missing workflow_runs'); - if (runs.length > MAX_FILTERED_WORKFLOW_RUNS) throw new ContractError('workflow runs page exceeds the requested page size'); - const records = new Map(); - for (const run of runs) { - const record = parseRunRecord(run, { workflowPath }); - if (records.has(record.runId)) throw new ContractError(`workflow run ${record.runId} is listed more than once`); - records.set(record.runId, record); - } - return [totalCount, [...records.values()]]; -} - -// _parse_run_record. -export function parseRunRecord(run, { workflowPath }) { - if (!isDict(run)) throw new ContractError('workflow run record must be a JSON object'); - if (!SUPPORTED_PIPELINE_WORKFLOW_PATHS.has(workflowPath)) throw new ContractError(`unsupported workflow path ${pyRepr(workflowPath)}`); - const runId = String(requirePositiveInt(pyGet(run, 'id'), 'workflow run id')); - if (!pyEquals(pyGet(run, 'path'), workflowPath)) { - throw new ContractError(`workflow run ${runId} has path ${pyRepr(pyGet(run, 'path'))}, expected ${pyRepr(workflowPath)}`); - } - if (!pyEquals(pyGet(run, 'event'), 'workflow_dispatch')) throw new ContractError(`workflow run ${runId} was not a workflow_dispatch run`); - // A workflow-level run-name replaces the static workflow label in the - // Actions API's name field. Bind machine identity to the exact path returned - // by the workflow-scoped endpoint above; the deterministic display_title - // below carries the correlation and pipeline inputs. - const runName = requireStr(pyGet(run, 'display_title'), `workflow run ${runId} display_title`); - for (const field of ['repository', 'head_repository']) { - const repository = pyGet(run, field); - if (!isDict(repository) || !pyEquals(pyGet(repository, 'full_name'), BRIDGE_REPOSITORY)) { - throw new ContractError(`workflow run ${runId} ${field} must be exactly ${BRIDGE_REPOSITORY}`); - } - } - for (const field of ['actor', 'triggering_actor']) { - const actor = pyGet(run, field); - if (!isDict(actor) || !pyEquals(pyGet(actor, 'login'), REPOSITORY_OWNER)) { - throw new ContractError(`workflow run ${runId} ${field} must be exactly ${REPOSITORY_OWNER}`); - } - } - const status = requireStr(pyGet(run, 'status'), `workflow run ${runId} status`); - const conclusion = pyGet(run, 'conclusion'); - if (conclusion !== null && typeof conclusion !== 'string') { - throw new ContractError(`workflow run ${runId} conclusion must be a string or null`); - } - if (status === 'completed' && !conclusion) throw new ContractError(`completed workflow run ${runId} has no conclusion`); - if (status !== 'completed' && !IN_FLIGHT_STATUSES.has(status)) { - throw new ContractError(`workflow run ${runId} has unsupported status ${pyRepr(status)}`); - } - const headSha = requireStr(pyGet(run, 'head_sha'), `workflow run ${runId} head_sha`); - if (COMMIT_RE.exec(headSha) === null) throw new ContractError(`workflow run ${runId} head_sha must be a 40-hex commit`); - const headBranch = requireStr(pyGet(run, 'head_branch'), `workflow run ${runId} head_branch`); - const runAttempt = requirePositiveInt(pyGet(run, 'run_attempt'), `workflow run ${runId} run_attempt`); - return new RunRecord({ runId, runName, status, conclusion, headBranch, headSha, runAttempt }); -} - -// `matched` and `unsuccessful` are tuples of RunRecord (frozen arrays). -export class RunSelection extends PyDataclass { - static FIELDS = Object.freeze([ - ['inFlightRunId', 'in_flight_run_id', REQUIRED], - ['succeededRunId', 'succeeded_run_id', REQUIRED], - ['matched', 'matched', Object.freeze([])], - ['unsuccessful', 'unsuccessful', Object.freeze([])], - ]); - - constructor(options) { - super(); - this.initFields(options); - this.matched = Object.freeze([...this.matched]); - this.unsuccessful = Object.freeze([...this.unsuccessful]); - Object.freeze(this); - } -} - -// Select at most one live and one successful run, or fail closed. -export function selectPipelineRuns(runs, { label, matcher, defaultBranch = 'main' }) { - const matched = []; - for (const record of runs) { - if (!matcher(record.runName)) continue; - if (!pyEquals(record.runAttempt, 1)) { - throw new ContractError(`${label} run ${record.runId} is attempt ${record.runAttempt}; pipeline stages are first-attempt-only`); - } - if (record.headBranch !== defaultBranch) { - throw new ContractError(`${label} run ${record.runId} ran from ${pyRepr(record.headBranch)}, not the ${pyRepr(defaultBranch)} main line`); - } - matched.push(record); - } - - const inFlight = matched.filter((record) => record.inFlight); - const succeeded = matched.filter((record) => record.succeeded); - const unsuccessful = matched.filter((record) => !record.inFlight && !record.succeeded); - if (inFlight.length > 1) { - throw new ContractError(`${inFlight.length} duplicate in-flight ${label} runs claim one pipeline stage: ${inFlight.map((record) => record.runId).join(', ')}`); - } - if (succeeded.length > 1) { - throw new ContractError(`${succeeded.length} duplicate successful ${label} runs claim one pipeline stage: ${succeeded.map((record) => record.runId).join(', ')}`); - } - if (inFlight.length > 0 && succeeded.length > 0) { - throw new ContractError( - `${label} stage has both a successful run (${succeeded[0].runId}) and an ` - + `in-flight run (${inFlight[0].runId}); resolve the duplicate dispatch`, - ); - } - return new RunSelection({ - inFlightRunId: inFlight.length > 0 ? inFlight[0].runId : null, - succeededRunId: succeeded.length > 0 ? succeeded[0].runId : null, - matched, - unsuccessful, - }); -} - -// _fetch_runs: the complete filtered history since `createdSince`. -export function fetchRuns(gateway, { - workflowFile, workflowPath, defaultBranch, createdSince, -}) { - const path = workflowRunsPath({ workflowFile, defaultBranch, createdSince }); - const [complete, records, initialTotal] = fetchCompleteRunQuery(gateway, { - workflowFile, - workflowPath, - defaultBranch, - createdSince, - createdUntil: null, - firstPayload: gateway.apiJson(path), - }); - if (complete) return records; - - // GitHub caps filtered workflow-run searches at 1,000 results. Partition a - // long-lived pipeline into closed 30-day windows and recursively split any - // saturated window, rather than treating a repository-lifetime total as a - // completeness proof or permanently failing after 1,000 later runs. - const start = parseUtcTimestamp(createdSince, 'workflow run lower bound'); - const endText = gateway.utcNow(); - const end = parseUtcTimestamp(endText, 'workflow run upper bound'); - if (end.seconds < start.seconds) throw new ContractError('workflow run history upper bound precedes lower bound'); - const collected = new Map(); - let cursor = start; - const windowSpan = RUN_HISTORY_WINDOW_DAYS * 86400 - 1; - while (cursor.seconds <= end.seconds) { - const candidateEnd = cursor.addSeconds(windowSpan); - const windowEnd = end.seconds < candidateEnd.seconds ? end : candidateEnd; - for (const record of fetchRunWindow(gateway, { - workflowFile, workflowPath, defaultBranch, start: cursor, end: windowEnd, - })) { - if (collected.has(record.runId)) throw new ContractError(`workflow run ${record.runId} appeared in multiple history windows`); - collected.set(record.runId, record); - } - cursor = windowEnd.addSeconds(1); - } - if (BigInt(collected.size) < big(initialTotal)) { - throw new ContractError( - `partitioned workflow run history for ${workflowPath} returned ` - + `${collected.size} records, below the initial count ${initialTotal}`, - ); - } - return [...collected.values()]; -} - -// _fetch_run_window: `start` and `end` are PyUtcDatetime values. -export function fetchRunWindow(gateway, { - workflowFile, workflowPath, defaultBranch, start, end, -}) { - const startText = formatUtcTimestamp(start); - const endText = formatUtcTimestamp(end); - const [complete, records] = fetchCompleteRunQuery(gateway, { - workflowFile, - workflowPath, - defaultBranch, - createdSince: startText, - createdUntil: endText, - }); - if (complete) return records; - if (start.seconds >= end.seconds) { - throw new ContractError(`workflow run history is saturated within second ${startText}; exact relevant history cannot be proven`); - } - const halfSeconds = Math.floor((end.seconds - start.seconds) / 2); - const midpoint = start.addSeconds(halfSeconds); - return [ - ...fetchRunWindow(gateway, { - workflowFile, workflowPath, defaultBranch, start, end: midpoint, - }), - ...fetchRunWindow(gateway, { - workflowFile, workflowPath, defaultBranch, start: midpoint.addSeconds(1), end, - }), - ]; -} - -// _fetch_complete_run_query: [complete, records, totalCount]. A null -// firstPayload (JSON null included, as Python's `is not None` test has it) -// fetches the first page. -export function fetchCompleteRunQuery(gateway, { - workflowFile, workflowPath, defaultBranch, createdSince, createdUntil, firstPayload = null, -}) { - const firstPath = workflowRunsPath({ - workflowFile, defaultBranch, createdSince, createdUntil, - }); - const payload = firstPayload !== null && firstPayload !== undefined ? firstPayload : gateway.apiJson(firstPath); - const [totalCount, firstRecords] = parseWorkflowRunsResponse(payload, { workflowPath }); - const expectedFirstPage = big(totalCount) < BigInt(MAX_FILTERED_WORKFLOW_RUNS) ? totalCount : MAX_FILTERED_WORKFLOW_RUNS; - if (BigInt(firstRecords.length) !== big(expectedFirstPage)) { - throw new ContractError( - `filtered workflow run first page for ${workflowPath} has ` - + `${firstRecords.length} records, expected ${expectedFirstPage}`, - ); - } - if (big(totalCount) >= BigInt(MAX_GITHUB_FILTERED_SEARCH_RESULTS)) return [false, [], totalCount]; - - const total = Number(totalCount); - const records = new Map(firstRecords.map((record) => [record.runId, record])); - const pageCount = Math.floor((total + MAX_FILTERED_WORKFLOW_RUNS - 1) / MAX_FILTERED_WORKFLOW_RUNS); - for (let page = 2; page <= pageCount; page += 1) { - const path = workflowRunsPath({ - workflowFile, defaultBranch, createdSince, createdUntil, page, - }); - const [pageTotal, pageRecords] = parseWorkflowRunsResponse(gateway.apiJson(path), { workflowPath }); - if (big(pageTotal) !== big(totalCount)) { - throw new ContractError(`filtered workflow run total changed during pagination for ${workflowPath}: ${totalCount} -> ${pageTotal}`); - } - const expectedPageSize = Math.min(MAX_FILTERED_WORKFLOW_RUNS, total - (page - 1) * MAX_FILTERED_WORKFLOW_RUNS); - if (pageRecords.length !== expectedPageSize) { - throw new ContractError( - `filtered workflow run page ${page} for ${workflowPath} has ` - + `${pageRecords.length} records, expected ${expectedPageSize}`, - ); - } - for (const record of pageRecords) { - if (records.has(record.runId)) throw new ContractError(`workflow run ${record.runId} is listed on multiple pages`); - records.set(record.runId, record); - } - } - if (records.size !== total) { - throw new ContractError( - `filtered workflow run listing for ${workflowPath} is incomplete: ` - + `${records.size} records for total_count=${totalCount}`, - ); - } - return [true, [...records.values()], totalCount]; -} - -// _parse_utc_timestamp: a PyUtcDatetime. A canonical-looking value that is -// not a real time raises strptime's ValueError, as in Python. -export function parseUtcTimestamp(value, label) { - if (pyFullmatch(UTC_TIMESTAMP_RE, value) === null) throw new ContractError(`${label} must be a canonical UTC timestamp`); - return PyUtcDatetime.strptime(value); -} - -// _format_utc_timestamp. -export function formatUtcTimestamp(value) { - if (!(value instanceof PyUtcDatetime)) throw new ContractError('workflow run timestamp has no timezone'); - return value.strftime(); -} - -// _workflow_runs_path. -export function workflowRunsPath({ - workflowFile, defaultBranch, createdSince, createdUntil = null, page = null, -}) { - if (pyFullmatch(UTC_TIMESTAMP_RE, createdSince) === null) { - throw new ContractError('workflow run lower bound must be a canonical UTC timestamp'); - } - if (createdUntil !== null && pyFullmatch(UTC_TIMESTAMP_RE, createdUntil) === null) { - throw new ContractError('workflow run upper bound must be a canonical UTC timestamp'); - } - const parameters = [ - ['per_page', String(MAX_FILTERED_WORKFLOW_RUNS)], - ['event', 'workflow_dispatch'], - ['branch', defaultBranch], - ['actor', REPOSITORY_OWNER], - ['created', createdUntil !== null ? `${createdSince}..${createdUntil}` : `>=${createdSince}`], - ]; - if (page !== null) { - if (page < 2) throw new ContractError('workflow run page must be at least 2'); - parameters.push(['page', String(page)]); - } - return `repos/${BRIDGE_REPOSITORY}/actions/workflows/${workflowFile}/runs?${pyUrlencode(parameters)}`; -} - -// _candidate_matcher. -export function candidateMatcher(correlationId) { - return (name) => parseCandidateRunName(name, correlationId) !== null; -} - -// _resolve_candidate_binding: recover the exact binding prior attempts -// persisted in their run names. -export function resolveCandidateBinding(selection, correlationId) { - const bindings = new PyValueSet(); - for (const record of selection.matched) { - const binding = parseCandidateRunName(record.runName, correlationId); - if (binding !== null) bindings.add(binding); - } - if (bindings.size > 1) { - throw new ContractError(`candidate runs for correlation ${pyRepr(correlationId)} advertise conflicting pipeline bindings`); - } - return bindings.size > 0 ? bindings[Symbol.iterator]().next().value : null; -} - -// _find_named_run. -export function findNamedRun(gateway, { - workflowFile, workflowPath, defaultBranch, createdSince, runName, -}) { - const runs = fetchRuns(gateway, { - workflowFile, workflowPath, defaultBranch, createdSince, - }); - const selection = selectPipelineRuns(runs, { - label: 'dispatched', - matcher: (name) => name === runName, - defaultBranch, - }); - return selection.inFlightRunId || selection.succeededRunId; -} - -// The newest page of a workflow's runs, with no server-side filter at all. -// -// The filtered search that fetchRuns pages through is the planner's complete -// history, but it can answer from a stale view: in scan 36232509530 it omitted -// a candidate run completed 26 minutes earlier, which the same query listed -// again seconds later. This listing takes a different server path (no -// created, actor, event or branch filter, newest run first), so the dispatch -// guard does not depend on one query shape. It is recency evidence, never a -// completeness proof: a run older than the newest page is left to fetchRuns. -export function recentWorkflowRunsPath(workflowFile) { - return `repos/${BRIDGE_REPOSITORY}/actions/workflows/${workflowFile}/runs?${pyUrlencode([ - ['per_page', String(MAX_FILTERED_WORKFLOW_RUNS)], - ])}`; -} - -// The records of the newest unfiltered page that belong to a pipeline, each -// parsed with parseRunRecord's full strictness. The page legitimately carries -// push, bot and topic-branch runs, so a run is kept when: -// -// - `correlated` accepts its display_title: the guarded pipeline's own run. -// It must be what the server filter of workflowRunsPath selects (a -// workflow_dispatch run by the repository owner on the default branch); -// anything else claiming the pipeline fails closed. -// - `select` accepts its display_title and the server filter would select it -// (other pipelines' runs, for claims). Other runs are skipped unparsed, as -// the server filter drops them. -export function fetchRecentRuns(gateway, { - workflowFile, workflowPath, defaultBranch, correlated, select = () => false, -}) { - requireStr(defaultBranch, 'default branch'); - const payload = gateway.apiJson(recentWorkflowRunsPath(workflowFile)); - if (!isDict(payload)) throw new ContractError('workflow runs response must be an object containing workflow_runs'); - const totalCount = pyGet(payload, 'total_count'); - if (!isPyInt(totalCount) || big(totalCount) < 0n) { - throw new ContractError('workflow runs total_count must be a non-negative integer'); - } - const runs = pyGet(payload, 'workflow_runs'); - if (!Array.isArray(runs)) throw new ContractError('workflow runs response is missing workflow_runs'); - if (runs.length > MAX_FILTERED_WORKFLOW_RUNS) throw new ContractError('workflow runs page exceeds the requested page size'); - const expectedPage = big(totalCount) < BigInt(MAX_FILTERED_WORKFLOW_RUNS) ? big(totalCount) : BigInt(MAX_FILTERED_WORKFLOW_RUNS); - if (BigInt(runs.length) !== expectedPage) { - throw new ContractError( - `newest workflow run page for ${workflowPath} has ${runs.length} records, expected ${expectedPage}`, - ); - } - const records = new Map(); - for (const run of runs) { - if (!isDict(run)) throw new ContractError('workflow run record must be a JSON object'); - const title = pyGet(run, 'display_title'); - const actor = pyGet(run, 'actor'); - const serverSelected = ( - pyEquals(pyGet(run, 'event'), 'workflow_dispatch') - && pyEquals(pyGet(run, 'head_branch'), defaultBranch) - && isDict(actor) - && pyEquals(pyGet(actor, 'login'), REPOSITORY_OWNER) - ); - if (correlated(title)) { - if (!serverSelected) { - throw new ContractError( - `workflow run ${pyRepr(pyGet(run, 'id'))} is named for this pipeline but is not a ` - + `workflow_dispatch run by ${REPOSITORY_OWNER} on ${pyRepr(defaultBranch)}`, - ); - } - } else if (!serverSelected || !select(title)) { - continue; - } - const record = parseRunRecord(run, { workflowPath }); - if (records.has(record.runId)) throw new ContractError(`workflow run ${record.runId} is listed more than once`); - records.set(record.runId, record); - } - return [...records.values()]; -} - -// Reconcile records of one workflow from reads taken at different moments, -// keyed by run id. A run keeps its name, branch and commit, and only moves -// forward (a new attempt, then completion), so the later state wins: a higher -// run_attempt, else a completed run over a live one. Views that disagree on a -// fixed field, or two completed views of one attempt that differ, cannot both -// be true and fail closed. -export function mergeRunRecords(...lists) { - const merged = new Map(); - for (const records of lists) { - for (const record of records) { - const known = merged.get(record.runId); - if (known === undefined) { - merged.set(record.runId, record); - continue; - } - if (known.equals(record)) continue; - if ( - known.runName !== record.runName - || known.headBranch !== record.headBranch - || known.headSha !== record.headSha - ) { - throw new ContractError(`workflow run ${record.runId} has contradictory identities across fresh reads`); - } - const attempts = BigInt(record.runAttempt) - BigInt(known.runAttempt); - if (attempts < 0n) continue; - if (attempts === 0n && !known.inFlight) { - if (record.inFlight) continue; - throw new ContractError(`workflow run ${record.runId} has contradictory outcomes across fresh reads`); - } - merged.set(record.runId, record); - } - } - return [...merged.values()]; -} diff --git a/scripts/release/publication_state.mjs b/scripts/release/publication_state.mjs index 0de311b..ea4fd40 100644 --- a/scripts/release/publication_state.mjs +++ b/scripts/release/publication_state.mjs @@ -155,18 +155,9 @@ export function validateCandidate(directory, identity) { }); } -// Validate immutable published bytes against an explicit historical contract. -export function validatePublishedCandidate(directory, identity, { - expectedQualificationGates, - expectedUnprovenCapabilities, -} = {}) { - return validateCandidateContract(directory, identity, { expectedQualificationGates, expectedUnprovenCapabilities }); -} - // Validate candidate bytes against an already selected manifest contract. -// -// The public strict and published-readback entry points select the contract; -// ordinary candidate callers cannot supply historical expectations. +// validateCandidate selects it, so callers cannot supply historical +// expectations. function validateCandidateContract(directory, identity, { expectedQualificationGates, expectedUnprovenCapabilities }) { const root = directoryPath(directory); if (!pyIsDir(root) || pyIsSymlink(root)) throw new ContractError('candidate must be a real directory'); diff --git a/scripts/release/python_compat.mjs b/scripts/release/python_compat.mjs index bca8a44..bcc53b2 100644 --- a/scripts/release/python_compat.mjs +++ b/scripts/release/python_compat.mjs @@ -9,10 +9,7 @@ import fs from 'node:fs'; import os, { constants as osConstants } from 'node:os'; import process from 'node:process'; -import { - PY_WHITESPACE, PyException, isPyException, pyPath, pyRepr, pyStrerror, pyStrftimeUtc, - pyStrptimeUtcOrRaise, pyTypeName, -} from './json.mjs'; +import { PyException, isPyException, pyPath, pyRepr, pyStrerror } from './json.mjs'; // True for the errors Python raises as OSError: Node system errors and a // PyException of the OSError family. @@ -181,109 +178,3 @@ export function pyExpanduser(text) { if (expanded.startsWith('~')) throw new PyException('RuntimeError', 'Could not determine home directory.'); return pyPath(`${expanded}${tail}`); } - -// --- re, str.split, tuple ordering and datetime for the orchestrator port ---- - -// re.fullmatch(pattern, value) for an anchored /^...$/ pattern: the match or -// null, raising TypeError for a non-str as re does. -export function pyFullmatch(pattern, value) { - if (typeof value !== 'string') { - throw new PyException('TypeError', `expected string or bytes-like object, got '${pyTypeName(value)}'`); - } - return pattern.exec(value); -} - -const WHITESPACE_RUN = new RegExp(`[${PY_WHITESPACE}]+`, 'u'); -const WHITESPACE_ONLY = new RegExp(`^[${PY_WHITESPACE}]*$`, 'u'); - -// str.split() with no separator: runs of str.isspace() characters split, -// leading and trailing whitespace dropped, no empty fields. -export function pyStrSplit(text) { - if (WHITESPACE_ONLY.test(text)) return []; - const fields = text.split(WHITESPACE_RUN); - if (fields[0] === '') fields.shift(); - if (fields.at(-1) === '') fields.pop(); - return fields; -} - -// Python ordering of two tuples of ints (numbers or bigints): negative, zero -// or positive. -export function pyCompareIntTuples(left, right) { - for (let index = 0; index < Math.min(left.length, right.length); index += 1) { - const a = BigInt(left[index]); - const b = BigInt(right[index]); - if (a !== b) return a < b ? -1 : 1; - } - return left.length - right.length; -} - -// Days from 0001-01-01 of a proleptic Gregorian date (datetime.toordinal() - 1). -function daysFromCivil(year, month, day) { - const y = month <= 2 ? year - 1 : year; - const era = Math.floor(y / 400); - const yoe = y - era * 400; - const doy = Math.floor((153 * (month + (month > 2 ? -3 : 9)) + 2) / 5) + day - 1; - const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy; - // 719162 days separate 0001-01-01 from 1970-01-01; 719468 separate 0000-03-01. - return era * 146097 + doe - 719468 + 719162; -} - -function civilFromDays(days) { - const z = days - 719162 + 719468; - const era = Math.floor(z / 146097); - const doe = z - era * 146097; - const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365); - const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100)); - const mp = Math.floor((5 * doy + 2) / 153); - const day = doy - Math.floor((153 * mp + 2) / 5) + 1; - const month = mp + (mp < 10 ? 3 : -9); - return { year: yoe + era * 400 + (month <= 2 ? 1 : 0), month, day }; -} - -const DAY_SECONDS = 86400; -// datetime.min and datetime.max (whole seconds), as seconds from datetime.min. -const MAX_SECONDS = (daysFromCivil(9999, 12, 31) + 1) * DAY_SECONDS - 1; - -// An aware UTC datetime with whole seconds, the subset of datetime the -// orchestrator's run-history windows use: strptime of the canonical -// "%Y-%m-%dT%H:%M:%SZ" form, adding seconds (OverflowError past datetime.min -// or datetime.max, as timedelta arithmetic raises it), comparison and strftime. -export class PyUtcDatetime { - constructor(seconds) { - if (!Number.isSafeInteger(seconds) || seconds < 0 || seconds > MAX_SECONDS) { - throw new PyException('OverflowError', 'date value out of range'); - } - this.seconds = seconds; - Object.freeze(this); - } - - static fromFields({ year, month, day, hour, minute, second }) { - return new PyUtcDatetime(daysFromCivil(year, month, day) * DAY_SECONDS + hour * 3600 + minute * 60 + second); - } - - // datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc). - static strptime(value) { - return PyUtcDatetime.fromFields(pyStrptimeUtcOrRaise(value)); - } - - // self + timedelta(seconds=delta). - addSeconds(delta) { - return new PyUtcDatetime(this.seconds + delta); - } - - fields() { - const days = Math.floor(this.seconds / DAY_SECONDS); - const rest = this.seconds - days * DAY_SECONDS; - return { - ...civilFromDays(days), - hour: Math.floor(rest / 3600), - minute: Math.floor((rest % 3600) / 60), - second: rest % 60, - }; - } - - // .strftime("%Y-%m-%dT%H:%M:%SZ") with glibc's unpadded %Y. - strftime() { - return pyStrftimeUtc(this.fields()); - } -} diff --git a/scripts/release/qualification.mjs b/scripts/release/qualification.mjs index 5fa674d..f11f332 100644 --- a/scripts/release/qualification.mjs +++ b/scripts/release/qualification.mjs @@ -66,7 +66,6 @@ import { CandidateIdentity, PUBLICATION_FILES, validateCandidate, - validatePublishedCandidate, } from './publication_state.mjs'; import { isOSError, osErrorString, pyResolve } from './python_compat.mjs'; import { @@ -899,25 +898,13 @@ export function requireInt(payload, key, label) { return value; } -// Validate a candidate directory against the current strict contract: -// [manifest, fingerprint]. -export function loadCandidate(directory) { - return loadCandidateContract(directory); -} - -// Validate immutable published bytes against an explicit historical contract. -export function loadPublishedCandidate(directory, { expectedQualificationGates, expectedUnprovenCapabilities } = {}) { - return loadCandidateContract(directory, { - publishedManifestContract: [expectedQualificationGates, expectedUnprovenCapabilities], - }); -} - -// Validate an exact candidate directory and return its manifest and digest. +// Validate an exact candidate directory against the current strict contract +// and return [manifest, fingerprint]. // // The candidate is validated with the same routine publication uses, so the // fingerprint an attestation binds is the identical fingerprint publication // recomputes; a divergent second definition could never be kept in step. -export function loadCandidateContract(directory, { publishedManifestContract = null } = {}) { +export function loadCandidate(directory) { const root = pyPath(String(directory)); if (!pyIsDir(root) || pyIsSymlink(root)) throw new ContractError(`candidate directory does not exist: ${root}`); const names = pyListdir(root); @@ -962,13 +949,7 @@ export function loadCandidateContract(directory, { publishedManifestContract = n githubRunId: requireStr(manifest, 'github_run_id', label), githubRunUrl: requireStr(manifest, 'github_run_url', label), }); - const fingerprint = publishedManifestContract === null - ? validateCandidate(root, identity) - : validatePublishedCandidate(root, identity, { - expectedQualificationGates: publishedManifestContract[0], - expectedUnprovenCapabilities: publishedManifestContract[1], - }); - return [manifest, fingerprint]; + return [manifest, validateCandidate(root, identity)]; } export function manifestCandidateGates(manifest) { diff --git a/tests/ci/ci_scope_test.mjs b/tests/ci/ci_scope_test.mjs index 5258c54..490568d 100644 --- a/tests/ci/ci_scope_test.mjs +++ b/tests/ci/ci_scope_test.mjs @@ -25,30 +25,11 @@ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../. 'tests/js/decision_api_contract_test.mjs', 'tests/js/mtmd_compat_contract_test.mjs', 'tests/js/wasm64_runtime_patch_contract_test.mjs', - 'scripts/stable_release_orchestrator.py', 'scripts/stable_release_orchestrator_test.py', - 'scripts/release_orchestrator_asset_releases.py', - 'scripts/release_orchestrator_asset_releases_test.py', 'scripts/release_orchestrator_driver.py', - 'scripts/release_orchestrator_driver_test.py', 'scripts/release_orchestrator_driver_backlog_test.py', - 'scripts/release_orchestrator_driver_identical_release_test.py', - 'scripts/release_orchestrator_driver_publication_test.py', 'scripts/release_orchestrator_fixtures_test.py', - 'scripts/release_orchestrator_model.py', 'scripts/release_orchestrator_native.py', - 'scripts/release_orchestrator_native_test.py', 'scripts/release_orchestrator_planner.py', - 'scripts/release_orchestrator_planner_test.py', 'scripts/release_orchestrator_release_tags.py', - 'scripts/release_orchestrator_release_tags_test.py', 'scripts/release_orchestrator_run_names.py', - 'scripts/release_orchestrator_run_names_test.py', 'scripts/release_orchestrator_stage_proofs.py', - 'scripts/release_orchestrator_transport.py', 'scripts/release_orchestrator_transport_test.py', - 'scripts/release_orchestrator_workflow_runs.py', 'scripts/release_orchestrator_workflow_runs_test.py', 'tests/js/worker_runtime_state_test.mjs', 'tests/js/declared_class_fields_test.mjs', 'scripts/verify_ci_reliability.mjs', 'tests/js/verify_ci_reliability_test.mjs', 'tests/release/contract_test.mjs', 'tests/release/fixtures/attestation.json', 'scripts/release/publication_state.mjs', - 'scripts/release/orchestrator/model.mjs', 'scripts/release/orchestrator/native.mjs', - 'scripts/release/orchestrator/planner.mjs', 'scripts/release/orchestrator/release_tags.mjs', - 'scripts/release/orchestrator/run_names.mjs', 'scripts/release/orchestrator/transport.mjs', - 'scripts/release/orchestrator/workflow_runs.mjs', 'tests/release/orchestrator/fixtures.mjs', - 'scripts/release/orchestrator/asset_releases.mjs', 'scripts/release/orchestrator/cli.mjs', - 'scripts/release/orchestrator/driver.mjs', 'scripts/release/orchestrator/stage_proofs.mjs', // The tooling-only files moved from tests/js/ and scripts/ keep skipping the lane. 'tests/bridge/native_core_source.mjs', 'tests/bridge/bridge_js_source.mjs', 'tests/bridge/state_persistence_api_contract_test.mjs', @@ -86,8 +67,6 @@ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../. 'CMakeLists.txt', 'docs/native.cpp', 'unknown.lock', // Only the release tests are exempt; the release modules keep the lanes. 'scripts/release/contract.mjs', 'scripts/release/qualification.mjs', 'tests/releases/x_test.mjs', - // A new orchestrator module is listed explicitly, never by prefix. - 'scripts/release/orchestrator/unlisted.mjs', // The files moved into scripts/build/, scripts/ci/, scripts/smoke/ and // tests/ keep the lanes their old paths kept. 'scripts/build/build_bridge.sh', 'scripts/build/build_js_bridge.mjs', 'scripts/build/patch_wasm64_runtime.mjs', diff --git a/tests/ci/verify_ci_reliability_test.mjs b/tests/ci/verify_ci_reliability_test.mjs index 328f2f3..c2de5dc 100644 --- a/tests/ci/verify_ci_reliability_test.mjs +++ b/tests/ci/verify_ci_reliability_test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { readdirSync, readFileSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; import path from 'node:path'; import { EXPECTED_MODEL_PINS as QUALIFICATION_MODEL_PINS } from '../../scripts/release/qualification.mjs'; @@ -12,24 +12,16 @@ import { RELEASE_CONTRACT_SUITES, RELEASE_MANIFEST_MODULE, collectErrors as collectRepositoryErrors, - ORCHESTRATOR_COMMAND, - ORCHESTRATOR_DIRECTORY, - ORCHESTRATOR_ENTRY, PUBLICATION_PAT_GUARD_ERROR, PUBLICATION_PAT_NAME, RELEASE_CONTRACT_COMMAND, RELEASE_QUALIFICATION_COMMAND, ROOT, - SANCTIONED_APPROVAL, TEST_COMMAND, - Workflow, checkJsContractTestsRegistered, - checkOrchestration, extractModelShaPinRoles, extractModelUrls, - literalDispatchBooleans, modelFileNameRoles, - orchestratorModules, parseExpectedModelPins, requireCanonicalModelShaPins, requireIdenticalModelUrls, @@ -556,207 +548,6 @@ assertRejected(validatePublicationPatContract('jobs: [unterminated', EXPECTED_ST }), ['tests/bridge/g.mjs']); } -// --- Orchestration --------------------------------------------------------- - -// The scan workflow runs the Node orchestrator and release CLIs, and the -// orchestrator source is the entry plus every module it imports. Each check -// is proven against a mutated copy of the real workflow and sources. -{ - const AUTO_UPDATE = '.github/workflows/auto_llama_cpp_update.yml'; - const workflowText = read(AUTO_UPDATE); - const sourceFiles = Object.fromEntries( - readdirSync(path.join(ROOT, ORCHESTRATOR_DIRECTORY)) - .filter((name) => name.endsWith('.mjs')) - .map((name) => [`${ORCHESTRATOR_DIRECTORY}/${name}`, read(`${ORCHESTRATOR_DIRECTORY}/${name}`)]), - ); - const sources = (files = sourceFiles) => { - const errors = []; - const modules = orchestratorModules(files, errors); - return { modules, errors }; - }; - const orchestrationErrors = (text = workflowText, modules = sources().modules) => { - const errors = []; - const workflow = new Workflow(AUTO_UPDATE, text, errors); - checkOrchestration(workflow, modules, errors); - return errors; - }; - const mutate = (old, replacement, text = workflowText) => { - assert.ok(text.includes(old), `the workflow no longer contains ${JSON.stringify(old)}`); - return text.split(old).join(replacement); - }; - - // The real workflow and sources pass, and the source is the entry first, - // then every module by path. - assert.deepEqual(sources().errors, []); - assert.deepEqual(Object.keys(sources().modules), [ - ORCHESTRATOR_ENTRY, - ...Object.keys(sourceFiles).filter((file) => file !== ORCHESTRATOR_ENTRY).sort(), - ]); - assert.deepEqual(orchestrationErrors(), []); - - // Each command needle: the Python command it replaced is rejected. - for (const [nodeCommand, pythonCommand, fragment] of [ - [`${RELEASE_QUALIFICATION_COMMAND} verify-run`, 'python3 scripts/release_qualification.py verify-run', - `with ${RELEASE_QUALIFICATION_COMMAND} verify-run`], - [`${RELEASE_CONTRACT_COMMAND} validate-environment`, 'python3 scripts/release_contract.py validate-environment', - `with ${RELEASE_CONTRACT_COMMAND} validate-environment`], - [`${RELEASE_CONTRACT_COMMAND} resolve-tag-commit`, 'python3 scripts/release_contract.py resolve-tag-commit', - `"${RELEASE_CONTRACT_COMMAND} resolve-tag-commit"`], - [`${RELEASE_CONTRACT_COMMAND} validate-native-release`, 'python3 scripts/release_contract.py validate-native-release', - `"${RELEASE_CONTRACT_COMMAND} validate-native-release"`], - [`${ORCHESTRATOR_COMMAND} resolve-bridge-source`, 'python3 scripts/stable_release_orchestrator.py resolve-bridge-source', - `"${ORCHESTRATOR_COMMAND} resolve-bridge-source"`], - [`${ORCHESTRATOR_COMMAND} scan-native`, 'python3 scripts/stable_release_orchestrator.py scan-native', - `"${ORCHESTRATOR_COMMAND} scan-native"`], - [`${ORCHESTRATOR_COMMAND} orchestrate-backlog`, 'python3 scripts/stable_release_orchestrator.py orchestrate-backlog', - `may hand the PAT only to ${ORCHESTRATOR_COMMAND} orchestrate-backlog`], - ]) { - assertRejected(orchestrationErrors(mutate(nodeCommand, pythonCommand)), fragment); - } - assertRejected(orchestrationErrors(mutate(`${ORCHESTRATOR_COMMAND} \\\n select-stable-native-backlog`, - 'python3 scripts/stable_release_orchestrator.py \\\n select-stable-native-backlog')), - `"${ORCHESTRATOR_COMMAND} select-stable-native-backlog"`); - // The Python orchestrator is never named again, even beside the Node commands. - assertRejected(orchestrationErrors(mutate('set -euo pipefail\n dry_run_flag=()', - 'set -euo pipefail\n python3 scripts/stable_release_orchestrator.py --help\n dry_run_flag=()')), - 'never run the deleted Python orchestrator'); - assertRejected(orchestrationErrors(`${workflowText}# scripts/release_orchestrator_driver.py\n`), - 'never run the deleted Python orchestrator'); - // Dispatch goes only through the orchestrator entry. - assertRejected(orchestrationErrors(mutate('set -euo pipefail\n dry_run_flag=()', - 'set -euo pipefail\n gh workflow run bridge_candidate.yml\n dry_run_flag=()')), - 'dispatch only through scripts/release/orchestrator/cli.mjs'); - assertRejected(orchestrationErrors(mutate(`${ORCHESTRATOR_COMMAND} orchestrate-backlog`, 'node scripts/release/other.mjs orchestrate-backlog')), - `may hand the PAT only to ${ORCHESTRATOR_COMMAND} orchestrate-backlog`); - // The environment is validated with the job token before the PAT step. - assertRejected(orchestrationErrors(mutate(' GH_TOKEN: ${{ github.token }}\n run: |\n set -euo pipefail\n gh api "repos/${BRIDGE_REPO}/environments', - ' GH_TOKEN: ${{ secrets.OTHER }}\n run: |\n set -euo pipefail\n gh api "repos/${BRIDGE_REPO}/environments')), - 'may use the PAT only inside the bridge-assets-publication environment'); - assertRejected(orchestrationErrors(mutate(' environment:\n name: bridge-assets-publication\n', '')), - 'may use the PAT only inside the bridge-assets-publication environment'); - assertRejected(orchestrationErrors(mutate('WEBGPU_BRIDGE_ASSETS_PAT: ${{ secrets.WEBGPU_BRIDGE_ASSETS_PAT }}', - 'RELEASE_CREDENTIAL: ${{ secrets.WEBGPU_BRIDGE_ASSETS_PAT }}')), `bind the publication PAT only as ${PUBLICATION_PAT_NAME}`); - - // Node.js 24 is set up without a cache before each job's first node - // command, and nothing installs packages. - const setupNode = ' - uses: actions/setup-node@v4\n with:\n node-version: 24\n'; - const setupCount = workflowText.split(setupNode).length - 1; - assert.equal(setupCount, 2); - const lastSetup = workflowText.lastIndexOf(setupNode); - const withoutAdvanceSetup = workflowText.slice(0, lastSetup) + workflowText.slice(lastSetup + setupNode.length); - assertRejected(orchestrationErrors(withoutAdvanceSetup), 'job advance_stable_release must set up Node.js 24'); - assertRejected(orchestrationErrors(mutate(setupNode, '')), 'job prepare_release_candidate must set up Node.js 24'); - assertRejected(orchestrationErrors(mutate('node-version: 24', 'node-version: 22')), 'must set up Node.js 24'); - assertRejected(orchestrationErrors(mutate(setupNode, `${setupNode} cache: npm\n`)), 'without a package cache'); - // Set up only after the first node command. - assertRejected(orchestrationErrors(mutate(`${setupNode}\n - name: Prove the exact workflow continuation before environment use\n`, - ' - name: Prove the exact workflow continuation before environment use\n') - .replace(' - name: Resolve native-aligned candidate\n', `${setupNode}\n - name: Resolve native-aligned candidate\n`)), - 'job prepare_release_candidate must set up Node.js 24 with actions/setup-node@v4 before its first node command'); - // Another major version could turn on package caching without a cache input. - assertRejected(orchestrationErrors(mutate('actions/setup-node@v4', 'actions/setup-node@v5')), 'with actions/setup-node@v4'); - for (const install of ['npm ci', 'npm install', 'npx --no-install playwright', 'pip install yaml', 'corepack enable']) { - assertRejected(orchestrationErrors(mutate('set -euo pipefail\n dry_run_flag=()', `set -euo pipefail\n ${install}\n dry_run_flag=()`)), - 'must never install or run packages'); - } - - // The orchestrator source: an unimported module, an import of a missing - // one, an import outside the orchestrator and the shared release modules, - // or a non-literal import() fails; so does a missing entry. - const extra = `${ORCHESTRATOR_DIRECTORY}/unimported.mjs`; - assertRejected(sources({ ...sourceFiles, [extra]: 'export const x = 1;\n' }).errors, `unreached: ${extra}`); - const driver = `${ORCHESTRATOR_DIRECTORY}/driver.mjs`; - assertRejected(sources({ ...sourceFiles, [driver]: `import './missing.mjs';\n${sourceFiles[driver]}` }).errors, - `imports ${ORCHESTRATOR_DIRECTORY}/missing.mjs, which does not exist`); - assertRejected(sources({ ...sourceFiles, [driver]: `import '../../ci/ci_scope.mjs';\n${sourceFiles[driver]}` }).errors, - 'imports scripts/ci/ci_scope.mjs, which is neither'); - assertRejected(sources({ ...sourceFiles, [driver]: `import '../qualify.mjs';\n${sourceFiles[driver]}` }).errors, - 'imports scripts/release/qualify.mjs, which is neither'); - assertRejected(sources({ ...sourceFiles, [driver]: `const m = await import(name);\n${sourceFiles[driver]}` }).errors, - 'has an import() whose target is not a string literal'); - // A module reached only through an unreached one is itself unreached. - const cut = { ...sourceFiles, [ORCHESTRATOR_ENTRY]: sourceFiles[ORCHESTRATOR_ENTRY].replace("import { advancePipeline } from './driver.mjs';\n", '') }; - assert.notEqual(cut[ORCHESTRATOR_ENTRY], sourceFiles[ORCHESTRATOR_ENTRY]); - assert.ok(sources(cut).errors.some((error) => /unreached: /.test(error) && error.split('unreached: ')[1].split(', ').includes(driver)), - sources(cut).errors.join('\n')); - const withoutEntry = { ...sourceFiles }; - delete withoutEntry[ORCHESTRATOR_ENTRY]; - assertRejected(sources(withoutEntry).errors, `${ORCHESTRATOR_ENTRY} is missing`); - assertRejected(orchestrationErrors(workflowText, {}), `must start at ${ORCHESTRATOR_ENTRY}`); - - // Row 53: every JavaScript spelling of a literal 'true' governance or - // approval boolean is found, in any orchestrator module. - for (const key of ['assets_immutable_releases_enabled', 'publish_approved']) { - for (const literal of [ - `inputs.${key} = 'true';`, - `inputs.${key}='true'`, - `inputs . ${key} = "true"`, - `inputs['${key}'] = 'true';`, - `inputs["${key}"] = \`true\``, - `const inputs = { ${key}: 'true' };`, - `{${key}:"true"}`, - `"${key}": "true"`, - `'${key}': 'true'`, - `new Map([['${key}', 'true']])`, - `inputs.set("${key}", 'true')`, - ]) { - assert.notDeepEqual(literalDispatchBooleans(literal), [], literal); - const errors = orchestrationErrors(workflowText, { ...sources().modules, [driver]: `${sourceFiles[driver]}\n${literal}\n` }); - assertRejected(errors, `${driver} must derive the governance and approval booleans only from live proofs`); - } - for (const allowed of [ - `inputs.${key} = governance.enabled === true ? 'true' : 'false';`, - `if (inputs.${key} === 'true') {}`, - `inputs.${key} == 'true'`, - `other_${key}: 'true'`, - `inputs.${key}_extra = 'true'`, - `'${key}',`, - `${key}: 'false'`, - ]) { - assert.deepEqual(literalDispatchBooleans(allowed), [], allowed); - } - } - // The approval is sanctioned only in the driver, exactly once, on the - // statement right after the live environment proof. - const proof = " requirePublicationEnvironment(gateway);\n inputs.publish_approved = 'true';"; - assert.ok(sourceFiles[driver].includes(proof)); - assert.equal(sourceFiles[driver].match(SANCTIONED_APPROVAL).length, 1); - assert.deepEqual(literalDispatchBooleans(sourceFiles[driver], { sanctioned: true }), []); - assert.deepEqual(literalDispatchBooleans(sourceFiles[driver]), [".publish_approved = 'true'"]); - assert.deepEqual(orchestrationErrors(workflowText, sources().modules), []); - const unproven = sourceFiles[driver].replace(proof, " inputs.publish_approved = 'true';"); - assert.deepEqual(literalDispatchBooleans(unproven, { sanctioned: true }), [".publish_approved = 'true'"]); - for (const [label, driverText, expected] of [ - ['a commented-out proof', sourceFiles[driver].replace(proof, " // requirePublicationEnvironment(gateway);\n inputs.publish_approved = 'true';"), - 'must derive the governance and approval booleans only from live proofs'], - ['a conditional proof', sourceFiles[driver].replace(proof, " if (false) requirePublicationEnvironment(gateway);\n inputs.publish_approved = 'true';"), - 'must derive the governance and approval booleans only from live proofs'], - ['a second sanctioned approval', `${sourceFiles[driver]}\n${proof}\n`, 'must assert publish_approved exactly once'], - ['no approval at all', sourceFiles[driver].replace(proof, ' requirePublicationEnvironment(gateway);'), 'must assert publish_approved exactly once'], - ]) { - assert.notEqual(driverText, sourceFiles[driver], label); - assertRejected(orchestrationErrors(workflowText, { ...sources().modules, [driver]: driverText }), expected); - } - // The same two statements in any other module are not sanctioned. - const planner = 'scripts/release/orchestrator/planner.mjs'; - assertRejected( - orchestrationErrors(workflowText, { ...sources().modules, [planner]: `${sourceFiles[planner]}\n${proof}\n` }), - `${planner} must derive the governance and approval booleans only from live proofs`, - ); - // The import walk reads every module the orchestrator can load. - for (const [label, line, expected] of [ - ['a package import', "import yaml from 'yaml';", 'may import only node: builtins and relative modules'], - ['a subpath import', "import x from '#internal';", 'may import only node: builtins and relative modules'], - ['an absolute URL import', "const x = await import('file:///tmp/x.mjs');", 'may import only node: builtins and relative modules'], - ['createRequire', "import { createRequire } from 'node:module';\nconst load = createRequire(import.meta.url);", 'loads modules through require'], - ['a bare require', "const x = require('./x.cjs');", 'loads modules through require'], - ]) { - const { errors } = sources({ ...sourceFiles, [planner]: `${sourceFiles[planner]}\n${line}\n` }); - assertRejected(errors, expected); - assert.ok(errors.every((error) => error.startsWith(planner)), label); - } -} - // --- Release workflow commands --------------------------------------------- // Every command needle of the candidate, qualification, publish and CI @@ -802,6 +593,9 @@ assertRejected(validatePublicationPatContract('jobs: [unterminated', EXPECTED_ST [CI_PATH, 'run: node scripts/ci/verify_ci_reliability.mjs', 'uses: docker://ghcr.io/astral-sh/uv:latest', 'runs Python (docker://ghcr.io/astral-sh/uv:latest)'], [CI_PATH, ' runs-on: ubuntu-latest\n', ' runs-on: ubuntu-latest\n defaults:\n run:\n shell: python3 {0}\n', 'runs Python (shell: python3 {0})', { once: true }], [CI_PATH, ' runs-on: ubuntu-latest\n', ' runs-on: ubuntu-latest\n container: python:3.12\n', 'runs Python (container: python:3.12)', { once: true }], + // R4: only the publication workflow may reference the PAT. + [QUALIFICATION_PATH, 'GH_TOKEN: ${{ github.token }}', 'GH_TOKEN: ${{ secrets.WEBGPU_BRIDGE_ASSETS_PAT }}', + 'references the secrets context; only .github/workflows/publish_assets.yml may', { once: true }], // R2: CI runs this contract. [CI_PATH, 'run: node scripts/ci/verify_ci_reliability.mjs', 'run: true', 'must run the CI reliability contract'], // R3: candidate and publish run the release suites through check:js. @@ -837,6 +631,8 @@ assertRejected(validatePublicationPatContract('jobs: [unterminated', EXPECTED_ST // Rows 73-76: exact provenance. [PUBLISH_PATH, 'node scripts/release/contract.mjs resolve-tag-commit', 'python3 scripts/release_contract.py resolve-tag-commit', 'must resolve the upstream and native tag commits itself', { once: true }], + [CANDIDATE_PATH, 'node scripts/release/contract.mjs validate-native-request', 'true', + `${CANDIDATE_PATH} must validate native tag, upstream commit, and manifest SHA-256 inputs before the first native network request`], [PUBLISH_PATH, 'node scripts/release/contract.mjs validate-native-request', 'python3 scripts/release_contract.py validate-native-request', 'must validate native tag, upstream commit, and manifest SHA-256 inputs before the first native network request'], [PUBLISH_PATH, 'node scripts/release/contract.mjs validate-native-release', 'python3 scripts/release_contract.py validate-native-release', diff --git a/tests/release/contract_test.mjs b/tests/release/contract_test.mjs index 13cde57..2a64e0a 100644 --- a/tests/release/contract_test.mjs +++ b/tests/release/contract_test.mjs @@ -24,7 +24,6 @@ import { requireCorrelationId, resolveNativeManifest, resolveTagCommit, - selectStableNativeRelease, validateCandidatePrequalification, validateGithubPrerelease, validateImmutableReleaseGovernance, @@ -100,18 +99,6 @@ test('test_github_prerelease_matches_native_policy', () => { assert.equal(validateGithubPrerelease('v0.2.0-llamadart.1', true), true); }); -test('test_stable_native_discovery_enumerates_wrappers', () => { - const releases = [ - { tag_name: 'b10599', draft: false, prerelease: true }, - { tag_name: 'v0.2.0', draft: false, prerelease: false }, - { tag_name: 'v0.2.0-2', draft: false, prerelease: true }, - { tag_name: 'v0.3.0-1', draft: false, prerelease: false }, - { tag_name: 'v9.0.0', draft: false, prerelease: true }, - { tag_name: 'v9.0.0', draft: true, prerelease: false }, - ]; - assert.equal(selectStableNativeRelease(releases), 'v0.2.0-2'); -}); - // int() refuses more than 4300 digits with ValueError, which escapes the // contract uncaught, as it does in Python. const TOO_LONG = 'ValueError: Exceeds the limit (4300 digits) for integer string conversion: value has 4301 digits; use sys.set_int_max_str_digits() to increase the limit'; @@ -125,10 +112,6 @@ test('a tag component over 4300 digits raises int()\'s ValueError, never passes' assert.throws(() => parseReleaseTag(`v1.0.0-llamadart.${ones}`, { allowLegacy: true }), tooLong); assert.throws(() => parseUpstreamTag(`v${ones}.0.0`), tooLong); assert.throws(() => parseUpstreamTag(`b${ones}`), tooLong); - assert.throws(() => selectStableNativeRelease([ - { tag_name: 'v0.2.0', draft: false, prerelease: false }, - { tag_name: `v${ones}.0.0`, draft: false, prerelease: false }, - ]), tooLong); const result = runCli(['validate-release', '--release-tag', `v${ones}.0.0`, '--release-rebuild', '0', '--upstream-tag', 'v1.0.0']); assert.deepEqual(result, { returncode: 1, stdout: '', stderr: `Traceback (most recent call last):\n${TOO_LONG}\n` }); }); @@ -307,7 +290,13 @@ test('test_native_request_is_validated_before_network_use', () => { '2e5d29d7f98f0d71e75d3fa63b7c55f3b2a7933247cc34ea2b1c5e053d142452', ); assert.equal(accepted.baseTag, 'v0.2.0'); + for (const nativeTag of ['v0.8.0', 'v0.8.0-3']) { + assert.equal(validateNativeRequest(nativeTag, 'v0.8.0', 'a'.repeat(40), 'b'.repeat(64)).channel, Channel.STABLE, nativeTag); + } for (const [label, nativeTag, upstreamTag, upstreamCommit, manifestSha256] of [ + ['development-tag', 'b10514', 'b10514', 'a'.repeat(40), 'b'.repeat(64)], + ['development-rebuild', 'b10514-2', 'b10514', 'a'.repeat(40), 'b'.repeat(64)], + ['legacy-wrapper', 'v0.2.0-llamadart.1', 'v0.2.0', 'a'.repeat(40), 'b'.repeat(64)], ['malformed-tag', 'v0.2.0-rc.1', 'v0.2.0', 'a'.repeat(40), 'b'.repeat(64)], ['wrong-upstream', 'v0.2.0-1', 'v0.2.1', 'a'.repeat(40), 'b'.repeat(64)], ['malformed-upstream', 'v0.2.0-1', 'main', 'a'.repeat(40), 'b'.repeat(64)], @@ -318,6 +307,19 @@ test('test_native_request_is_validated_before_network_use', () => { } }); +test('validate-native-request rejects a development native release by name', () => { + for (const tag of ['b10514', 'b10514-2']) { + assert.deepEqual(runCli([ + 'validate-native-request', '--native-release-tag', tag, '--upstream-tag', 'b10514', + '--upstream-commit', 'a'.repeat(40), '--manifest-sha256', 'b'.repeat(64), + ]), { + returncode: 1, + stdout: '', + stderr: `error: native release tag '${tag}' is not a stable release; use vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-N\n`, + }, tag); + } +}); + test('test_channel_and_rebuild_ordering', () => { const cases = [ ['b10514', 'b10515', Transition.FORWARD], diff --git a/tests/release/manifest_test.mjs b/tests/release/manifest_test.mjs index 33cdb00..1f42133 100644 --- a/tests/release/manifest_test.mjs +++ b/tests/release/manifest_test.mjs @@ -17,10 +17,8 @@ import { artifactBytes, coreWasm } from './core_wasm_fixture.mjs'; import * as V0_1_53 from './manifest_v0_1_53_fixture.mjs'; import { loadCandidate } from './publication_fixtures.mjs'; -// The capabilities of every published schema-v2 manifest (v0.1.44 to v0.1.46). -// The orchestrator re-verifies published releases with -// release_qualification.load_candidate, which requires the generator's exact -// capabilities, so a new capability needs a historical readback contract first. +// The capabilities of every published schema-v2 manifest (v0.1.44 to v0.1.46), +// which loadCandidate must keep accepting. const PUBLISHED_CAPABILITIES = { wasm32: true, memory64: true, diff --git a/tests/release/orchestrator/asset_releases_test.mjs b/tests/release/orchestrator/asset_releases_test.mjs deleted file mode 100644 index 622f067..0000000 --- a/tests/release/orchestrator/asset_releases_test.mjs +++ /dev/null @@ -1,401 +0,0 @@ -// Tests of scripts/release/orchestrator/asset_releases.mjs, one test per test -// method of scripts/release_orchestrator_asset_releases_test.py, with the same -// names and assertions. A Python subTest loop is one loop inside its test. - -import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { test } from 'node:test'; - -import { ASSETS_REPOSITORY, ContractError, NATIVE_REPOSITORY } from '../../../scripts/release/contract.mjs'; -import { pyGet, pyItems } from '../../../scripts/release/json.mjs'; -import { ARTIFACTS } from '../../../scripts/release/manifest.mjs'; -import * as assetReleases from '../../../scripts/release/orchestrator/asset_releases.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as runNames from '../../../scripts/release/orchestrator/run_names.mjs'; -import { PUBLICATION_FILES, candidatePublicationDigests } from '../../../scripts/release/publication_state.mjs'; -import { loadCandidate } from '../../../scripts/release/qualification.mjs'; -import { releaseAttestation } from '../contract_fixtures.mjs'; -import { - ADVANCED_BRIDGE_SHA, ASSETS_TAG_COMMIT, BRIDGE_SHA, FakeGateway, LEGACY_MANUAL_QUALIFICATION_GATES, - LEGACY_MANUAL_UNPROVEN_CAPABILITIES, NATIVE_MANIFEST_SHA, alignedReleaseStub, assetReleaseStub, directoryMembers, - makeLegacyV0140Provenance, makeProvenance, releasePayload, writeBridgeCandidate, -} from './fixtures.mjs'; -import { replaceProvenance } from './driver_fixtures.mjs'; - -function sha256Hex(data) { - return createHash('sha256').update(data).digest('hex'); -} - -// --- PublishedNativeAlignmentTest ------------------------------------------------ - -function alignmentRelease(tag, nativeTags, fields = {}) { - const release = assetReleaseStub(tag); - release.body = nativeTags.map((nativeTag) => `Native: \`${NATIVE_REPOSITORY}@${nativeTag}\`\r\n`).join(''); - Object.assign(release, fields); - return release; -} - -test('test_no_recorded_alignment_keeps_every_provenance_eligible', () => { - assert.equal(assetReleases.latestPublishedNativeAlignment([]), null); - assert.equal( - assetReleases.latestPublishedNativeAlignment([assetReleaseStub('v0.1.37'), { tag_name: 'v0.1.36', body: null }]), - null, - ); -}); - -test('test_newest_native_order_wins_regardless_of_listing_order', () => { - const releases = [ - alignmentRelease('v0.1.44', ['v0.4.1']), - alignmentRelease('v0.1.45', ['v0.4.1-1']), - alignmentRelease('v0.1.42', ['v0.3.0']), - ]; - assert.deepEqual(assetReleases.latestPublishedNativeAlignment(releases), ['v0.4.1-1', 'v0.1.45']); -}); - -test('test_only_published_stable_native_markers_are_evidence', () => { - const foreign = assetReleaseStub('v0.1.47'); - foreign.body = 'Native: `someone/else@v9.9.9`\nsee Native: `x@v9.9.9`\n'; - const releases = [ - alignmentRelease('v0.1.44', ['v0.4.1']), - alignmentRelease('v0.1.45', ['v0.5.0'], { draft: true }), - alignmentRelease('v0.1.46', ['v0.5.0'], { draft: null }), - alignmentRelease('b9165', ['b9165'], { prerelease: true }), - alignmentRelease('b9170', ['v0.5.0'], { prerelease: true }), - alignmentRelease('v0.1.48', ['b9165']), - alignmentRelease('not-a-release', ['v0.5.0']), - foreign, - ]; - assert.deepEqual(assetReleases.latestPublishedNativeAlignment(releases), ['v0.4.1', 'v0.1.44']); -}); - -test('test_contradictory_or_malformed_alignment_fails_closed', () => { - for (const release of [ - alignmentRelease('v0.1.44', ['v0.4.0', 'v0.4.1']), - alignmentRelease('v0.1.44', ['v0.4']), - alignmentRelease('v0.1.44', ['v0.4.1-llamadart.1']), - ]) { - assert.throws(() => assetReleases.latestPublishedNativeAlignment([release]), ContractError, release.body); - } - assert.deepEqual( - assetReleases.latestPublishedNativeAlignment([alignmentRelease('v0.1.44', ['v0.4.1', 'v0.4.1'])]), - ['v0.4.1', 'v0.1.44'], - ); -}); - -test('newest published stable release ignores drafts and other channels', () => { - const releases = [ - assetReleaseStub('v0.1.41'), - assetReleaseStub('b9999'), - { ...assetReleaseStub('v0.1.50'), draft: true }, - assetReleaseStub('v0.1.42'), - assetReleaseStub('v0.1.40'), - ]; - assert.equal(assetReleases.newestPublishedStableRelease(releases).tag_name, 'v0.1.42'); - assert.equal(assetReleases.newestPublishedStableRelease([assetReleaseStub('b9999')]), null); -}); - -// --- IdenticalPublicationTest ------------------------------------------------------ - -function withIdentical(fn) { - return () => { - const tmp = fs.mkdtempSync(path.join(tmpdir(), 'sro-identical-')); - const provenance = makeProvenance(); - const self = { - tmp, - provenance, - prior: replaceProvenance(provenance, { bridgeSourceSha: ADVANCED_BRIDGE_SHA, bridgeBuildSha: ADVANCED_BRIDGE_SHA }), - digests(name, { marker, tag, provenance: bound }) { - const directory = path.join(tmp, name); - writeBridgeCandidate(directory, { - releaseTag: tag, - releaseRebuild: 0, - correlationId: runNames.computeCorrelationId(bound), - bridgeCommit: bound.bridgeSourceSha, - runId: String(700 + name.length), - marker: Buffer.from(marker), - }); - return candidatePublicationDigests(directory); - }, - }; - try { - fn(self); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }; -} - -test('test_manifest_is_the_only_file_that_differs_between_identical_builds', withIdentical((self) => { - const published = self.digests('published', { marker: 'same', tag: 'v0.1.41', provenance: self.prior }); - const candidate = self.digests('candidate', { marker: 'same', tag: 'v0.1.42', provenance: self.provenance }); - assert.deepEqual( - PUBLICATION_FILES.filter((name) => pyGet(published, name) !== pyGet(candidate, name)), - ['manifest.json'], - ); - assert.equal(assetReleases.publicationBytesIdentical(candidate, published), true); -})); - -test('test_one_differing_artifact_is_not_identical', withIdentical((self) => { - const published = self.digests('published', { marker: 'same', tag: 'v0.1.41', provenance: self.prior }); - const candidate = self.digests('candidate', { marker: 'changed', tag: 'v0.1.42', provenance: self.provenance }); - assert.equal(assetReleases.publicationBytesIdentical(candidate, published), false); -})); - -test('test_incomplete_inventory_fails_closed', withIdentical((self) => { - const published = self.digests('published', { marker: 'same', tag: 'v0.1.41', provenance: self.prior }); - const candidate = self.digests('candidate', { marker: 'same', tag: 'v0.1.42', provenance: self.provenance }); - for (const side of ['candidate', 'published']) { - const partial = Object.fromEntries(pyItems(side === 'candidate' ? candidate : published).filter(([name]) => name !== ARTIFACTS[0])); - assert.throws(() => assetReleases.publicationBytesIdentical( - side === 'candidate' ? partial : candidate, - side === 'candidate' ? published : partial, - ), ContractError, side); - } -})); - -test('test_latest_aligned_release_is_the_newest_tag_for_this_native', withIdentical((self) => { - const otherNative = makeProvenance({ nativeReleaseTag: 'v0.2.1', nativeManifestSha256: 'f'.repeat(64) }); - const releases = [ - alignedReleaseStub('v0.1.41', self.prior), - alignedReleaseStub('v0.1.41-2', self.prior), - alignedReleaseStub('v0.1.42', otherNative), - alignedReleaseStub('v0.1.41-1', self.prior), - ]; - const selected = assetReleases.latestAlignedRelease(releases, self.provenance); - assert.notEqual(selected, null); - assert.equal(selected[0].tag_name, 'v0.1.41-2'); - assert.equal(selected[1], runNames.computeCorrelationId(self.prior)); - assert.equal(assetReleases.latestAlignedRelease(releases.slice(2, 3), self.provenance), null); -})); - -test('test_latest_aligned_release_skips_drafts_and_ambiguous_correlations', withIdentical((self) => { - const draft = alignedReleaseStub('v0.1.41', self.prior); - draft.draft = true; - assert.equal(assetReleases.latestAlignedRelease([draft], self.provenance), null); - const ambiguous = alignedReleaseStub('v0.1.41', self.prior); - ambiguous.body += `Orchestrator correlation: \`${runNames.computeCorrelationId(self.provenance)}\`\n`; - const malformed = alignedReleaseStub('v0.1.41-1', self.prior); - malformed.body = malformed.body.replace(runNames.computeCorrelationId(self.prior), 'not a correlation'); - const older = alignedReleaseStub('v0.1.41', self.prior); - for (const releases of [[ambiguous], [malformed]]) { - assert.equal(assetReleases.latestAlignedRelease(releases, self.provenance), null); - } - const selected = assetReleases.latestAlignedRelease([older, ambiguous, malformed], self.provenance); - assert.equal(selected[0], older); -})); - -test('test_legacy_v0140_is_comparable_only_by_its_own_provenance', () => { - const legacy = makeLegacyV0140Provenance(); - const release = alignedReleaseStub('v0.1.40', legacy); - assert.notEqual(assetReleases.latestAlignedRelease([release], legacy), null); - const governed = makeLegacyV0140Provenance({ bridgeBuildSha: ADVANCED_BRIDGE_SHA }); - assert.equal(assetReleases.latestAlignedRelease([release], governed), null); -}); - -// --- PublishedReleaseVerificationTest --------------------------------------------- - -function withPublished(fn) { - return () => { - const tmp = fs.mkdtempSync(path.join(tmpdir(), 'sro-published-')); - try { - const provenance = makeProvenance(); - const correlationId = runNames.computeCorrelationId(provenance); - const candidate = path.join(tmp, 'candidate'); - writeBridgeCandidate(candidate, { releaseTag: 'v0.1.40', releaseRebuild: 0, correlationId }); - const members = directoryMembers(candidate); - const fingerprint = loadCandidate(candidate)[1]; - const body = `Candidate fingerprint: \`${fingerprint}\`\nOrchestrator correlation: \`${correlationId}\`\n`; - const self = { - tmp, - provenance, - correlationId, - candidate, - members, - fingerprint, - body, - release: releasePayload({ tag: 'v0.1.40', body, members }), - gatewayForRelease(release, { releaseById = null, tagCommit = ASSETS_TAG_COMMIT, attestation = null } = {}) { - const blobs = {}; - for (const asset of release.assets) { - if (Object.hasOwn(members, asset.name)) blobs[`repos/${ASSETS_REPOSITORY}/releases/assets/${asset.id}`] = members[asset.name]; - } - const tag = release.tag_name; - const releaseId = release.id; - const digests = Object.fromEntries(Object.entries(members).map(([name, data]) => [name, sha256Hex(data)])); - const routes = { - [`repos/${ASSETS_REPOSITORY}/git/ref/tags/${tag}`]: { ref: `refs/tags/${tag}`, object: { type: 'commit', sha: tagCommit } }, - [`repos/${ASSETS_REPOSITORY}/releases/tags/${tag}`]: release, - [`repos/${ASSETS_REPOSITORY}/releases/${releaseId}`]: releaseById || release, - }; - const attestations = [[[ASSETS_REPOSITORY, tag], attestation || releaseAttestation({ - releaseTag: tag, assetsRepo: ASSETS_REPOSITORY, tagCommit, releaseId, assets: digests, - })]]; - return new FakeGateway({ jsonRoutes: routes, blobRoutes: blobs, releaseAttestations: attestations }); - }, - verify(release, options = {}) { - return assetReleases.verifyPublishedRelease(self.gatewayForRelease(release, options), { - release, provenance, correlationId, workspace: path.join(tmp, 'workspace'), - }); - }, - }; - fn(self); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }; -} - -test('test_exact_immutable_publication_is_accepted', withPublished((self) => { - const verified = self.verify(self.release); - assert.equal(verified.releaseTarget.releaseTag, 'v0.1.40'); - assert.equal(verified.releaseTarget.releaseRebuild, 0); - assert.equal(verified.binding.bridgeSourceSha, BRIDGE_SHA); -})); - -test('test_only_exact_v0140_identity_selects_legacy_manifest_contract', () => { - const exact = makeLegacyV0140Provenance(); - assert.deepEqual( - model.publishedManifestCompatibility({ tag: 'v0.1.40', provenance: exact }), - [LEGACY_MANUAL_QUALIFICATION_GATES, LEGACY_MANUAL_UNPROVEN_CAPABILITIES], - ); - const nearMisses = { - 'release tag': ['v0.1.40-1', exact], - 'native tag': ['v0.1.40', makeLegacyV0140Provenance({ nativeReleaseTag: 'v0.3.0-1' })], - 'native commit': ['v0.1.40', makeLegacyV0140Provenance({ nativeCommit: '1'.repeat(40) })], - 'upstream tag': ['v0.1.40', makeLegacyV0140Provenance({ upstreamTag: 'v0.3.1' })], - 'upstream commit': ['v0.1.40', makeLegacyV0140Provenance({ upstreamCommit: '2'.repeat(40) })], - 'governed bridge build': ['v0.1.40', makeLegacyV0140Provenance({ bridgeBuildSha: '3'.repeat(40) })], - 'native manifest': ['v0.1.40', makeLegacyV0140Provenance({ nativeManifestSha256: '0'.repeat(64) })], - }; - for (const [label, [tag, provenance]] of Object.entries(nearMisses)) { - assert.equal(model.publishedManifestCompatibility({ tag, provenance }), null, label); - } -}); - -test('test_native_manifest_marker_prevents_duplicate_when_correlation_is_damaged', withPublished((self) => { - const malformed = { - ...self.release, - body: `Native: \`${NATIVE_REPOSITORY}@v0.2.0\`\nNative manifest SHA-256: \`${NATIVE_MANIFEST_SHA}\`\n`, - }; - assert.equal(assetReleases.findCorrelatedRelease([malformed], self.correlationId, self.provenance), malformed); - assert.throws(() => self.verify(malformed), ContractError); -})); - -test('test_manifest_digest_without_exact_native_tag_cannot_hijack_state', withPublished((self) => { - const foreign = { - ...self.release, - body: `Native: \`${NATIVE_REPOSITORY}@v9.9.9\`\nNative manifest SHA-256: \`${NATIVE_MANIFEST_SHA}\`\n`, - }; - assert.equal(assetReleases.findCorrelatedRelease([foreign], self.correlationId, self.provenance), null); -})); - -test('test_mutable_release_fails_closed', withPublished((self) => { - assert.throws(() => self.verify({ ...self.release, immutable: false }), ContractError); -})); - -test('test_missing_immutability_field_fails_closed', withPublished((self) => { - const release = { ...self.release }; - delete release.immutable; - assert.throws(() => self.verify(release), ContractError); -})); - -test('test_missing_published_at_fails_closed', withPublished((self) => { - assert.throws(() => self.verify({ ...self.release, published_at: null }), ContractError); -})); - -test('test_draft_release_fails_closed', withPublished((self) => { - assert.throws(() => self.verify({ ...self.release, draft: true }), ContractError); -})); - -test('test_incomplete_asset_inventory_fails_closed', withPublished((self) => { - const release = { ...self.release, assets: self.release.assets.filter((asset) => asset.name !== 'sha256sums.txt') }; - assert.throws(() => self.verify(release), ContractError); -})); - -test('test_unexpected_extra_asset_fails_closed', withPublished((self) => { - const release = { - ...self.release, - assets: [...self.release.assets, { - id: 999, name: 'extra.bin', state: 'uploaded', size: 1, digest: `sha256:${'0'.repeat(64)}`, - }], - }; - assert.throws(() => self.verify(release), ContractError); -})); - -test('test_digest_mismatch_fails_closed', withPublished((self) => { - const release = releasePayload({ - tag: 'v0.1.40', - body: self.body, - members: self.members, - assetOverrides: { 'manifest.json': { digest: `sha256:${'0'.repeat(64)}` } }, - }); - assert.throws(() => self.verify(release), ContractError); -})); - -test('test_manifest_bytes_that_do_not_bind_provenance_fail_closed', withPublished((self) => { - assert.throws(() => assetReleases.verifyPublishedRelease(self.gatewayForRelease(self.release), { - release: self.release, - provenance: makeProvenance({ nativeCommit: 'd'.repeat(40) }), - correlationId: self.correlationId, - workspace: path.join(self.tmp, 'workspace'), - }), ContractError); -})); - -test('test_release_tag_commit_is_resolved_independently', withPublished((self) => { - assert.throws(() => self.verify(self.release, { tagCommit: 'd'.repeat(40) }), ContractError); -})); - -test('test_release_readback_by_id_must_match', withPublished((self) => { - assert.throws(() => self.verify(self.release, { releaseById: { ...self.release, body: 'unrelated' } }), ContractError); -})); - -test('test_signed_release_attestation_must_bind_every_asset', withPublished((self) => { - const invalid = releaseAttestation({ - releaseTag: 'v0.1.40', - assetsRepo: ASSETS_REPOSITORY, - tagCommit: ASSETS_TAG_COMMIT, - releaseId: self.release.id, - assets: { 'manifest.json': '0'.repeat(64) }, - }); - assert.throws(() => self.verify(self.release, { attestation: invalid }), ContractError); -})); - -test('test_release_body_without_the_candidate_fingerprint_fails_closed', withPublished((self) => { - // The publication contract binds the release body to the exact candidate - // digest. A body that only names the correlation proves nothing about - // which bytes were published. - const release = releasePayload({ - tag: 'v0.1.40', body: `Orchestrator correlation: \`${self.correlationId}\`\n`, members: self.members, - }); - assert.throws(() => self.verify(release), ContractError); -})); - -test('test_release_body_with_a_foreign_candidate_fingerprint_fails_closed', withPublished((self) => { - const release = releasePayload({ - tag: 'v0.1.40', - body: `Candidate fingerprint: \`${'0'.repeat(64)}\`\nOrchestrator correlation: \`${self.correlationId}\`\n`, - members: self.members, - }); - assert.throws(() => self.verify(release), ContractError); -})); - -test('test_release_tag_that_contradicts_the_published_manifest_fails_closed', withPublished((self) => { - assert.throws(() => self.verify(releasePayload({ tag: 'v0.1.41', body: self.body, members: self.members })), ContractError); -})); - -test('test_publication_from_an_earlier_bridge_source_stays_a_noop', withPublished((self) => { - // Main advances daily; the published release stays bound to the exact - // candidate source it was built from, not to today's HEAD. - const verified = self.verify(self.release); - assert.equal(verified.binding.bridgeSourceSha, BRIDGE_SHA); - const advanced = assetReleases.verifyPublishedRelease(self.gatewayForRelease(self.release), { - release: self.release, - provenance: makeProvenance({ bridgeSourceSha: ADVANCED_BRIDGE_SHA }), - correlationId: self.correlationId, - workspace: path.join(self.tmp, 'workspace'), - }); - assert.equal(advanced.binding.bridgeSourceSha, BRIDGE_SHA); -})); diff --git a/tests/release/orchestrator/cli_test.mjs b/tests/release/orchestrator/cli_test.mjs deleted file mode 100644 index d2efee8..0000000 --- a/tests/release/orchestrator/cli_test.mjs +++ /dev/null @@ -1,513 +0,0 @@ -// Tests of scripts/release/orchestrator/cli.mjs, the CLI entry: governed-path -// classification, bridge source identity, and caller authorization. One test -// per test method of the deleted scripts/stable_release_orchestrator_test.py, -// with the same names, assertions and case lists, plus Node-only checks: the -// classifier sets are pinned by those case lists, and the command-line -// surface. - -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { test } from 'node:test'; - -import { ArgparseExit } from '../../../scripts/release/cli.mjs'; -import { ContractError } from '../../../scripts/release/contract.mjs'; -import { pyJsonLoads } from '../../../scripts/release/json.mjs'; -import * as sro from '../../../scripts/release/orchestrator/cli.mjs'; -import { OWNER } from './fixtures.mjs'; - -const ROOT = path.resolve(import.meta.dirname, '..', '..', '..'); - -// --- BridgeSourceIdentityTest ------------------------------------------------------ - -function withRepository(fn) { - return () => { - const temp = fs.mkdtempSync(path.join(tmpdir(), 'sro-cli-')); - const repository = path.join(temp, 'bridge'); - fs.mkdirSync(repository); - const git = (...args) => execFileSync('git', ['-C', repository, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); - git('init', '--initial-branch=main'); - git('config', 'user.name', 'Bridge Test'); - git('config', 'user.email', 'bridge-test@example.com'); - const commit = (message) => { - git('add', '.'); - git('commit', '-m', message); - return git('rev-parse', 'HEAD'); - }; - try { - fn({ repository, git, commit }); - } finally { - fs.rmSync(temp, { recursive: true, force: true }); - } - }; -} - -function write(file, text) { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, text, 'utf8'); -} - -test('test_actual_resolver_separates_orchestration_from_governed_changes', withRepository(({ repository, commit }) => { - write(path.join(repository, 'src', 'bridge.cpp'), 'runtime-v1\n'); - const governed = commit('initial runtime'); - - write(path.join(repository, '.github', 'workflows', 'auto_llama_cpp_update.yml'), 'name: scan\n'); - write(path.join(repository, 'docs', 'release.md'), 'release docs\n'); - write(path.join(repository, 'scripts', 'resolver_test.py'), '# regression\n'); - write(path.join(repository, 'scripts', 'ci_scope.py'), '# selector\n'); - const orchestrationHead = commit('workflow tests and docs'); - - const output = path.join(repository, 'identity.json'); - assert.equal(sro.main( - ['resolve-bridge-source', '--repository', repository, '--head', 'HEAD', '--output-json', output], - { stdout: () => {}, stderr: () => {} }, - ), 0); - const identity = pyJsonLoads(fs.readFileSync(output, 'utf8')); - assert.equal(identity.bridge_source_sha, orchestrationHead); - assert.equal(identity.bridge_build_sha, governed); - - // An unclassified new file is governed by default. This protects the - // identity if a future build input is added without updating the list. - write(path.join(repository, 'new-build-input.cfg'), 'runtime-v2\n'); - const governedHead = commit('new governed build input'); - const advanced = sro.resolveBridgeSourceIdentity(repository); - assert.equal(advanced.bridgeSourceSha, governedHead); - assert.equal(advanced.bridgeBuildSha, governedHead); -})); - -test('test_malformed_git_path_is_rejected', () => { - assert.throws(() => sro.isGovernedBridgePath('../outside'), ContractError); -}); - -// The orchestration-only and governed paths of the deleted Python suite, -// plus every other ORCHESTRATION_ONLY_PATHS entry. -const ORCHESTRATION_ONLY_CASES = Object.freeze([ - '.github/workflows/auto_llama_cpp_update.yml', - 'README.md', - 'docs/api.md', - 'scripts/bridge_js_source.py', - 'scripts/bridge_operation_queue_direct_cases.mjs', - 'scripts/bridge_operation_queue_fixtures.mjs', - 'scripts/bridge_operation_queue_lifecycle_contract_cases.mjs', - 'scripts/bridge_operation_queue_worker_proxy_cases.mjs', - 'scripts/browser_smoke_support.mjs', - 'scripts/ci_scope.mjs', - 'scripts/ci_scope.py', - 'scripts/ci_scope_test.py', - 'scripts/decision_browser_smoke.mjs', - 'scripts/decision_browser_smoke.py', - 'scripts/grammar_browser_smoke.mjs', - 'scripts/grammar_browser_smoke.py', - 'scripts/mtmd_compat_contract_test.py', - 'scripts/multimodal_browser_smoke.mjs', - 'scripts/multimodal_browser_smoke.py', - 'scripts/native_core_source.py', - 'scripts/next_token_scores_browser_smoke.mjs', - 'scripts/next_token_scores_browser_smoke.py', - 'scripts/orchestrator_source.py', - 'scripts/release_orchestrator_asset_releases.py', - 'scripts/release_orchestrator_asset_releases_test.py', - 'scripts/release_orchestrator_driver.py', - 'scripts/release_orchestrator_driver_backlog_test.py', - 'scripts/release_orchestrator_driver_identical_release_test.py', - 'scripts/release_orchestrator_driver_publication_test.py', - 'scripts/release_orchestrator_driver_test.py', - 'scripts/release_orchestrator_fixtures_test.py', - 'scripts/release_orchestrator_model.py', - 'scripts/release_orchestrator_native.py', - 'scripts/release_orchestrator_native_test.py', - 'scripts/release_orchestrator_planner.py', - 'scripts/release_orchestrator_planner_test.py', - 'scripts/release_orchestrator_release_tags.py', - 'scripts/release_orchestrator_release_tags_test.py', - 'scripts/release_orchestrator_run_names.py', - 'scripts/release_orchestrator_run_names_test.py', - 'scripts/release_orchestrator_stage_proofs.py', - 'scripts/release_orchestrator_transport.py', - 'scripts/release_orchestrator_transport_test.py', - 'scripts/release_orchestrator_workflow_runs.py', - 'scripts/release_orchestrator_workflow_runs_test.py', - 'scripts/release_qualification.py', - 'scripts/speech_to_text_browser_smoke.mjs', - 'scripts/speech_to_text_browser_smoke.py', - 'scripts/speech_to_text_fixture.json', - 'scripts/stable_release_orchestrator.py', - 'scripts/stable_release_orchestrator_test.py', - 'scripts/state_persistence_browser_smoke.mjs', - 'scripts/state_persistence_browser_smoke.py', - 'scripts/text_to_speech_browser_smoke.mjs', - 'scripts/text_to_speech_browser_smoke.py', - 'scripts/verify_ci_reliability.mjs', - 'scripts/verify_ci_reliability.py', - 'scripts/verify_ci_reliability_doc_facts_test.py', - 'scripts/verify_ci_reliability_pin_test.py', - 'scripts/verify_decision_api.py', - 'scripts/verify_state_persistence_api.py', - 'scripts/verify_text_to_speech_api.py', - 'scripts/wasm64_runtime_patch_contract_test.py', - 'scripts/worker_runtime_state_test.mjs', - 'tests/js/bridge_js_source.mjs', - 'tests/js/bridge_operation_queue_direct_cases.mjs', - 'tests/js/bridge_operation_queue_fixtures.mjs', - 'tests/js/bridge_operation_queue_lifecycle_contract_cases.mjs', - 'tests/js/bridge_operation_queue_worker_proxy_cases.mjs', - 'tests/js/browser_smoke_support_test.mjs', - 'tests/js/ci_scope_test.mjs', - 'tests/js/decision_api_contract_test.mjs', - 'tests/js/mtmd_compat_contract_test.mjs', - 'tests/js/multimodal_harness_parity_test.mjs', - 'tests/js/native_core_source.mjs', - 'tests/js/state_persistence_api_contract_test.mjs', - 'tests/js/state_persistence_harness_parity_test.mjs', - 'tests/js/text_to_speech_api_contract_test.mjs', - 'tests/js/verify_ci_reliability_test.mjs', - 'tests/js/wasm64_runtime_patch_contract_test.mjs', - 'tests/js/worker_runtime_state_test.mjs', - 'tests/js/nested/new_contract_test.mjs', - 'tests/new_suite/new_contract_test.mjs', - 'tests/release/contract_test.mjs', - 'scripts/release/archive.mjs', - 'scripts/release/publication_state.mjs', - 'scripts/release/qualification.mjs', - 'scripts/release/qualify.mjs', - 'scripts/release/wav.mjs', - 'scripts/release/orchestrator/asset_releases.mjs', - 'scripts/release/orchestrator/cli.mjs', - 'scripts/release/orchestrator/driver.mjs', - 'scripts/release/orchestrator/model.mjs', - 'scripts/release/orchestrator/native.mjs', - 'scripts/release/orchestrator/planner.mjs', - 'scripts/release/orchestrator/release_tags.mjs', - 'scripts/release/orchestrator/run_names.mjs', - 'scripts/release/orchestrator/stage_proofs.mjs', - 'scripts/release/orchestrator/transport.mjs', - 'scripts/release/orchestrator/workflow_runs.mjs', - 'tests/release/zip_fixture.mjs', - 'tests/release/orchestrator/fixtures.mjs', - 'tests/release/fixtures/attestation.json', - // The CI tooling, smokes and test helpers at their paths after the move - // into scripts/ci/, scripts/smoke/ and tests//. - 'scripts/ci/ci_scope.mjs', - 'scripts/ci/verify_ci_reliability.mjs', - 'scripts/smoke/decision.mjs', - 'scripts/smoke/grammar.mjs', - 'scripts/smoke/lora_adapter.mjs', - 'scripts/smoke/multimodal.mjs', - 'scripts/smoke/next_token_scores.mjs', - 'scripts/smoke/speculative.mjs', - 'scripts/smoke/speech_to_text.mjs', - 'scripts/smoke/speech_to_text_fixture.json', - 'scripts/smoke/state_persistence.mjs', - 'scripts/smoke/support.mjs', - 'scripts/smoke/text_to_speech.mjs', - 'tests/bridge/bridge_js_source.mjs', - 'tests/bridge/bridge_operation_queue_direct_cases.mjs', - 'tests/bridge/bridge_operation_queue_fixtures.mjs', - 'tests/bridge/bridge_operation_queue_lifecycle_contract_cases.mjs', - 'tests/bridge/bridge_operation_queue_worker_proxy_cases.mjs', - 'tests/bridge/native_core_source.mjs', - 'tests/bridge/decision_api_contract_test.mjs', - 'tests/build/wasm64_runtime_patch_contract_test.mjs', - 'tests/ci/ci_scope_test.mjs', - 'tests/ci/verify_ci_reliability_test.mjs', - 'tests/smoke/browser_smoke_support_test.mjs', - 'tests/smoke/heavy_browser_smokes_test.mjs', - // Every other listed path, so the lists pin ORCHESTRATION_ONLY_PATHS. - '.gitignore', - '.github/workflows/bridge_qualification.yml', - '.github/workflows/ci.yml', - '.github/workflows/publish_assets.yml', - 'AGENTS.md', - 'CONTRIBUTING.md', - 'LICENSE', - 'scripts/release_publication_state.py', -]); - -const GOVERNED_CASES = Object.freeze([ - 'CMakeLists.txt', - '.github/workflows/bridge_candidate.yml', - 'package.json', - 'js/llama_webgpu_bridge.d.ts', - 'scripts/build_bridge.sh', - 'scripts/generate_release_manifest.py', - // The wasm64 runtime patch rewrites a published asset: its Python - // original and the Node port are both build inputs. - 'scripts/patch_wasm64_runtime.mjs', - 'scripts/patch_wasm64_runtime.py', - 'scripts/release_contract.py', - // A new orchestrator module is listed explicitly, never by prefix. - 'scripts/release_orchestrator_unlisted.py', - // So is a new smoke helper; only the smoke entry points match by suffix. - 'scripts/browser_smoke_unlisted.mjs', - 'scripts/browser_smoke.mjs', - // Only the speech fixture is listed; another data file is governed. - 'scripts/text_to_speech_fixture.json', - 'scripts/verify_emscripten_version.py', - 'src/llama_webgpu_core.cpp', - 'src/core/exports_tts.inc', - 'src/core/new_part.inc', - 'tests/js/new_fixture.mjs', - 'tests/new_build_input.mjs', - // Only tests/release/ is exempt as a whole. - 'tests/releases/new_fixture.mjs', - 'scripts/release/contract.mjs', - 'scripts/release/errors.mjs', - 'scripts/release/json.mjs', - 'scripts/release/cli.mjs', - 'scripts/release/manifest.mjs', - 'scripts/release/python_compat.mjs', - 'scripts/release/unlisted.mjs', - 'scripts/release/orchestrator/unlisted.mjs', - 'scripts/build/verify_emscripten_version.mjs', - // The build scripts stay build inputs after the move into scripts/build/. - 'scripts/build/build_bridge.sh', - 'scripts/build/build_js_bridge.mjs', - 'scripts/build/patch_wasm64_runtime.mjs', - // The new directories are not exempt by prefix. - 'scripts/build/unlisted.mjs', - 'scripts/ci/unlisted.mjs', - 'scripts/smoke/unlisted.mjs', - 'scripts/smoke/text_to_speech_fixture.json', - 'tests/bridge/new_fixture.mjs', - 'tests/ci/new_fixture.mjs', - 'tests/smoke/new_fixture.mjs', - 'unknown/new-build-input.cfg', -]); - -test('test_path_contract_is_fail_closed_for_runtime_and_new_inputs', () => { - for (const file of ORCHESTRATION_ONLY_CASES) { - assert.equal(sro.isGovernedBridgePath(file), false, file); - } - for (const file of GOVERNED_CASES) { - assert.equal(sro.isGovernedBridgePath(file), true, file); - } -}); - -// --- OrchestrationCallerAuthorizationTest ---------------------------------------- - -const WORKFLOW = path.join(ROOT, '.github', 'workflows', 'auto_llama_cpp_update.yml'); - -test('test_schedule_owner_manual_and_owner_continuation_are_authorized', () => { - sro.requireOrchestrationCaller('schedule', 'github-actions', 'github-actions'); - sro.requireOrchestrationCaller('workflow_dispatch', OWNER, OWNER); - sro.requireOrchestrationCaller('workflow_run', OWNER, OWNER); -}); - -test('test_non_owner_manual_or_continuation_is_rejected', () => { - for (const event of ['workflow_dispatch', 'workflow_run']) { - for (const [actor, triggeringActor] of [['collaborator', OWNER], [OWNER, 'collaborator']]) { - assert.throws(() => sro.requireOrchestrationCaller(event, actor, triggeringActor), ContractError, `${event} ${actor}`); - } - } -}); - -test('test_workflow_run_trigger_and_both_job_gates_are_exact', () => { - const workflow = fs.readFileSync(WORKFLOW, 'utf8'); - const count = (needle) => workflow.split(needle).length - 1; - assert.ok(workflow.includes('fetch-depth: 0')); - assert.ok(workflow.includes('node scripts/release/orchestrator/cli.mjs resolve-bridge-source')); - assert.ok(workflow.includes('--bridge-build-sha "${bridge_build_sha}"')); - const manualGate = ( - "github.event_name == 'workflow_dispatch' && " - + 'github.actor == github.repository_owner && ' - + 'github.triggering_actor == github.repository_owner' - ); - const continuationGate = ( - "github.event_name == 'workflow_run' && " - + "github.event.workflow_run.conclusion == 'success' && " - + 'github.event.workflow_run.head_branch == ' - + 'github.event.repository.default_branch && ' - + 'github.event.workflow_run.actor.login == github.repository_owner && ' - + 'github.event.workflow_run.triggering_actor.login == ' - + 'github.repository_owner' - ); - for (const trigger of [ - '- Build Exact Bridge Candidate', - '- Qualify Exact Bridge Candidate', - '- Publish Exact Qualified Bridge Assets', - 'types: [completed]', - ]) { - assert.ok(workflow.includes(trigger), trigger); - } - assert.equal(count(manualGate), 2); - assert.equal(count(continuationGate), 2); - assert.ok(workflow.lastIndexOf(manualGate) < workflow.indexOf('environment:')); - assert.ok(workflow.lastIndexOf(continuationGate) < workflow.indexOf('environment:')); - const proof = 'Prove the exact workflow continuation before environment use'; - assert.ok(workflow.includes(proof)); - const marker = ` - name: ${proof}\n`; - const proofBlock = workflow.slice(workflow.indexOf(marker) + marker.length).split('\n - name:')[0]; - assert.ok(proofBlock.includes('node scripts/release/qualification.mjs verify-run')); - assert.ok(proofBlock.includes('--run-attempt 1')); - for (const exactMapping of [ - '.github/workflows/bridge_candidate.yml)\n artifact_name=exact-webgpu-bridge-dist', - '.github/workflows/bridge_qualification.yml)\n artifact_name=qualification-attestation', - '.github/workflows/publish_assets.yml)\n artifact_name=bridge-qualification-outcome', - ]) { - assert.ok(proofBlock.includes(exactMapping), exactMapping); - } - assert.ok(workflow.indexOf(proof) < workflow.indexOf('environment:')); -}); - -// --- Node only ------------------------------------------------------------------ - -// The classifier is the source of truth; the case lists pin it. Every listed -// path is an orchestration-only case, so dropping an entry fails the path -// contract above and adding one needs a case here. -test('the case lists pin every classifier set', () => { - const cases = new Set(ORCHESTRATION_ONLY_CASES); - assert.equal(cases.size, ORCHESTRATION_ONLY_CASES.length); - assert.equal(new Set(GOVERNED_CASES).size, GOVERNED_CASES.length); - for (const listed of sro.ORCHESTRATION_ONLY_PATHS) assert.ok(cases.has(listed), listed); - assert.deepEqual(sro.ORCHESTRATION_ONLY_PREFIXES, ['docs/', 'tests/release/']); - assert.deepEqual(sro.ORCHESTRATION_ONLY_SCRIPT_SUFFIXES, ['_browser_smoke.mjs', '_browser_smoke.py', '_test.mjs', '_test.py']); - assert.equal(sro.ORCHESTRATION_ONLY_JS_TEST_SUFFIX, '_test.mjs'); - // Every orchestrator module is listed explicitly, never by prefix. - const modules = fs.readdirSync(path.join(ROOT, 'scripts', 'release', 'orchestrator')) - .filter((name) => name.endsWith('.mjs')) - .map((name) => `scripts/release/orchestrator/${name}`); - for (const module of modules) assert.ok(sro.ORCHESTRATION_ONLY_PATHS.has(module), module); -}); - -// The Python orchestrator, deleted when the scan workflow switched to this -// entry, stays orchestration-only, so history never classifies the deleting -// commit as a build input. -test('the deleted Python orchestrator stays orchestration-only', () => { - const modules = [ - 'asset_releases', 'driver', 'model', 'native', 'planner', 'release_tags', 'run_names', 'stage_proofs', 'transport', - 'workflow_runs', - ]; - const suites = [ - 'asset_releases', 'driver', 'driver_backlog', 'driver_identical_release', 'driver_publication', 'fixtures', 'native', - 'planner', 'release_tags', 'run_names', 'transport', 'workflow_runs', - ]; - const deleted = [ - 'scripts/stable_release_orchestrator.py', - 'scripts/stable_release_orchestrator_test.py', - ...modules.map((name) => `scripts/release_orchestrator_${name}.py`), - ...suites.map((name) => `scripts/release_orchestrator_${name}_test.py`), - ]; - assert.equal(deleted.length, 24); - for (const file of deleted) { - assert.equal(fs.existsSync(path.join(ROOT, file)), false, file); - assert.ok(ORCHESTRATION_ONLY_CASES.includes(file), file); - assert.equal(sro.isGovernedBridgePath(file), false, file); - } -}); - -// The layout move into scripts/build/, scripts/ci/, scripts/smoke/ and -// tests// keeps every file's classification, and its old path stays -// classified as before, so history does not change. -test('moved files keep the classification of their old paths', () => { - const smokes = [ - 'decision', 'grammar', 'lora_adapter', 'multimodal', 'next_token_scores', 'speculative', 'speech_to_text', - 'state_persistence', 'text_to_speech', - ]; - const bridgeTests = fs.readdirSync(path.join(ROOT, 'tests', 'bridge')).map((name) => [`tests/js/${name}`, `tests/bridge/${name}`]); - const moves = [ - ['scripts/build_bridge.sh', 'scripts/build/build_bridge.sh'], - ['scripts/build_js_bridge.mjs', 'scripts/build/build_js_bridge.mjs'], - ['scripts/patch_wasm64_runtime.mjs', 'scripts/build/patch_wasm64_runtime.mjs'], - ['scripts/ci_scope.mjs', 'scripts/ci/ci_scope.mjs'], - ['scripts/verify_ci_reliability.mjs', 'scripts/ci/verify_ci_reliability.mjs'], - ['scripts/browser_smoke_support.mjs', 'scripts/smoke/support.mjs'], - ['scripts/speech_to_text_fixture.json', 'scripts/smoke/speech_to_text_fixture.json'], - ...smokes.map((name) => [`scripts/${name}_browser_smoke.mjs`, `scripts/smoke/${name}.mjs`]), - ...bridgeTests, - ['tests/js/ci_scope_test.mjs', 'tests/ci/ci_scope_test.mjs'], - ['tests/js/verify_ci_reliability_test.mjs', 'tests/ci/verify_ci_reliability_test.mjs'], - ['tests/js/wasm64_runtime_patch_contract_test.mjs', 'tests/build/wasm64_runtime_patch_contract_test.mjs'], - ['tests/js/browser_smoke_support_test.mjs', 'tests/smoke/browser_smoke_support_test.mjs'], - ['tests/js/heavy_browser_smokes_test.mjs', 'tests/smoke/heavy_browser_smokes_test.mjs'], - ]; - assert.ok(bridgeTests.length > 0); - for (const [before, after] of moves) { - assert.ok(fs.existsSync(path.join(ROOT, after)), after); - assert.equal(fs.existsSync(path.join(ROOT, before)), false, before); - assert.equal(sro.isGovernedBridgePath(after), sro.isGovernedBridgePath(before), `${before} -> ${after}`); - } - for (const governed of ['scripts/build/build_bridge.sh', 'scripts/build/build_js_bridge.mjs', 'scripts/build/patch_wasm64_runtime.mjs']) { - assert.equal(sro.isGovernedBridgePath(governed), true, governed); - } -}); - -test('invalid paths are rejected with the Python repr', () => { - for (const [value, repr] of [ - ['', "''"], ['/abs', "'/abs'"], ['a\\b', "'a\\\\b'"], ['a//b', "'a//b'"], ['a/./b', "'a/./b'"], ['a/', "'a/'"], - [null, 'None'], [7, '7'], - ]) { - assert.throws(() => sro.isGovernedBridgePath(value), new ContractError(`git reported an invalid repository path: ${repr}`)); - } -}); - -test('the caller gate names the accepted callers', () => { - assert.throws( - () => sro.requireOrchestrationCaller('push', OWNER, OWNER), - new ContractError( - 'stable orchestration requires a schedule event, or an owner-initiated ' - + 'workflow_dispatch or workflow_run continuation with owner actor and triggering_actor', - ), - ); -}); - -test('orchestrating subcommands check the caller before any credential use', () => { - for (const subcommand of ['orchestrate', 'orchestrate-backlog']) { - const argv = [subcommand, subcommand === 'orchestrate' ? '--provenance-json' : '--provenance-list-json', 'missing.json', '--workspace', 'unused']; - assert.throws(() => sro.main(argv, { - env: { GITHUB_EVENT_NAME: 'pull_request' }, - createGateway: () => assert.fail('no gateway before the caller gate'), - stdout: () => {}, - stderr: () => {}, - }), ContractError); - } -}); - -test('the command line is argparse-compatible and never looser', () => { - const parse = (argv) => sro.parseCommand(argv); - assert.deepEqual(parse(['orchestrate', '--provenance-json', 'p.json', '--workspace', 'w/', '--dry-run']), { - subcommand: 'orchestrate', - args: { - provenanceJson: 'p.json', workspace: 'w', outputPlanJson: null, stepSummaryFile: null, dryRun: true, - }, - }); - assert.equal(parse(['orchestrate-backlog', '--provenance-list-json', 'p', '--workspace', 'w']).args.dryRun, false); - assert.equal(parse(['resolve-bridge-source', '--repository', '.']).args.head, 'HEAD'); - const usage = (argv, status = 2) => assert.throws(() => parse(argv), (error) => error instanceof ArgparseExit && error.status === status); - usage([]); - usage(['bogus']); - usage(['orchestrate', '--workspace', 'w']); - usage(['orchestrate', '--provenance-json', 'p', '--workspace', 'w', '--dry-run=yes']); - usage(['orchestrate', '--provenance-json', 'p', '--workspace', '--dry-run']); - usage(['orchestrate', '--provenance-json', 'p', '--workspace', 'w', '--dry']); - usage(['orchestrate', '--provenance-json', 'p', '--workspace', 'w', 'extra']); - usage([ - 'scan-native', '--manifest', 'm', '--native-release-tag', 't', '--bridge-source-sha', 's', '--bridge-build-sha', 'b', - '--native-release-published-at', 'p', '--channel', 'nightly', - ]); - usage(['-h'], 0); - usage(['orchestrate', '--help'], 0); - assert.equal(parse([ - 'scan-native', '--manifest', 'm', '--native-release-tag', 't', '--bridge-source-sha', 's', '--bridge-build-sha', 'b', - '--native-release-published-at', 'p', '--channel', 'development', - ]).args.channel, 'development'); -}); - -test('runMain reports errors as the Python entry does', () => { - const run = (argv, env = {}) => { - let stdout = ''; - let stderr = ''; - const status = sro.runMain(argv, { env, stdout: (text) => { stdout += text; }, stderr: (text) => { stderr += text; } }); - return { status, stdout, stderr }; - }; - const denied = run(['orchestrate', '--provenance-json', 'p', '--workspace', 'w'], { GITHUB_EVENT_NAME: 'push' }); - assert.equal(denied.status, 1); - assert.match(denied.stderr, /^error: stable orchestration requires a schedule event/u); - const bogus = run(['bogus']); - assert.equal(bogus.status, 2); - assert.match(bogus.stderr, /error: argument subcommand: invalid choice: 'bogus'/u); - const missing = run(['select-stable-native-backlog', '--releases-json', path.join(tmpdir(), 'sro-cli-missing', 'releases.json')]); - assert.equal(missing.status, 1); - assert.match(missing.stderr, /^Traceback \(most recent call last\):\nFileNotFoundError: \[Errno 2\] No such file or directory: /u); -}); diff --git a/tests/release/orchestrator/driver_backlog_test.mjs b/tests/release/orchestrator/driver_backlog_test.mjs deleted file mode 100644 index ad8e184..0000000 --- a/tests/release/orchestrator/driver_backlog_test.mjs +++ /dev/null @@ -1,456 +0,0 @@ -// Tests of advancePipeline across an ordered native backlog: publication -// barriers, superseded natives, and published noops. One test per test method -// of scripts/release_orchestrator_driver_backlog_test.py, with the same names -// and assertions. A Python subTest loop is one loop inside its test. - -import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import process from 'node:process'; -import { test } from 'node:test'; - -import { ASSETS_REPOSITORY, BRIDGE_REPOSITORY, ContractError } from '../../../scripts/release/contract.mjs'; -import { pyJsonDumps, pyJsonLoads } from '../../../scripts/release/json.mjs'; -import * as orchestrator from '../../../scripts/release/orchestrator/cli.mjs'; -import * as assetReleases from '../../../scripts/release/orchestrator/asset_releases.mjs'; -import * as driver from '../../../scripts/release/orchestrator/driver.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as releaseTags from '../../../scripts/release/orchestrator/release_tags.mjs'; -import * as runNames from '../../../scripts/release/orchestrator/run_names.mjs'; -import { - ATTESTATION_ARTIFACT_NAME, CANDIDATE_ARTIFACT_NAME, loadCandidate, loadPublishedCandidate, -} from '../../../scripts/release/qualification.mjs'; -import { releaseAttestation } from '../contract_fixtures.mjs'; -import { - ADVANCED_BRIDGE_SHA, ASSETS_TAG_COMMIT, BRIDGE_SHA, CANDIDATE_ARTIFACT_ID, CANDIDATE_RUN_ID, DEFAULT_BRANCH, FakeGateway, - HEAD_SHA, LEGACY_BRIDGE_SHA, LEGACY_CANDIDATE_RUN_ID, LEGACY_MANUAL_QUALIFICATION_GATES, LEGACY_MANUAL_UNPROVEN_CAPABILITIES, - LEGACY_NATIVE_COMMIT, LEGACY_NATIVE_MANIFEST_SHA, LEGACY_UPSTREAM_COMMIT, QUALIFICATION_ARTIFACT_ID, QUALIFICATION_RUN_ID, - alignedReleaseStub, artifactInventory, assetReleaseStub, directoryMembers, flatZip, makeLegacyV0140Provenance, - makeProvenance, releasePayload, rewriteLegacyCandidateManifest, runPayload, runsResponse, withAdvancePipelineFixture, - writeBridgeCandidate, -} from './fixtures.mjs'; -import { - attestationBytes, onDispatch, replaceProvenance, runsKey, -} from './driver_fixtures.mjs'; - -const RUNS = `repos/${BRIDGE_REPOSITORY}/actions/runs`; -const ARTIFACTS = `repos/${BRIDGE_REPOSITORY}/actions/artifacts`; - -function withFixture(fn) { - return withAdvancePipelineFixture(fn, { orchestrator }); -} - -// _published_candidate_gateway. -function publishedCandidateGateway(self, candidateDir, { provenance, correlationId, publishedManifestCompatibility = null }) { - const members = directoryMembers(candidateDir); - const manifest = pyJsonLoads(fs.readFileSync(path.join(candidateDir, 'manifest.json'), 'utf8')); - const fingerprint = publishedManifestCompatibility === null - ? loadCandidate(candidateDir)[1] - : loadPublishedCandidate(candidateDir, { - expectedQualificationGates: publishedManifestCompatibility[0], - expectedUnprovenCapabilities: publishedManifestCompatibility[1], - })[1]; - const body = `Candidate fingerprint: \`${fingerprint}\`\nOrchestrator correlation: \`${correlationId}\`\n`; - const release = releasePayload({ tag: 'v0.1.40', body, members }); - const routes = self.routes({ releases: [release] }); - Object.assign(routes, { - [`repos/${BRIDGE_REPOSITORY}/compare/${provenance.bridgeSourceSha}...${DEFAULT_BRANCH}`]: { status: 'ahead' }, - [`repos/${BRIDGE_REPOSITORY}/compare/${manifest.bridge_commit}...${DEFAULT_BRANCH}`]: { status: 'ahead' }, - [`repos/${ASSETS_REPOSITORY}/git/ref/tags/v0.1.40`]: { ref: 'refs/tags/v0.1.40', object: { type: 'commit', sha: ASSETS_TAG_COMMIT } }, - [`repos/${ASSETS_REPOSITORY}/releases/tags/v0.1.40`]: release, - [`repos/${ASSETS_REPOSITORY}/releases/${release.id}`]: release, - }); - const blobs = Object.fromEntries(release.assets.map((asset) => [ - `repos/${ASSETS_REPOSITORY}/releases/assets/${asset.id}`, members[asset.name], - ])); - const digests = Object.fromEntries(Object.entries(members).map(([name, data]) => [name, createHash('sha256').update(data).digest('hex')])); - return new FakeGateway({ - jsonRoutes: routes, - blobRoutes: blobs, - releaseAttestations: [[[ASSETS_REPOSITORY, 'v0.1.40'], releaseAttestation({ - releaseTag: 'v0.1.40', assetsRepo: ASSETS_REPOSITORY, tagCommit: ASSETS_TAG_COMMIT, releaseId: release.id, assets: digests, - })]], - }); -} - -test('test_later_qualified_provenance_waits_for_earlier_immutable_publication', async () => { - const root = fs.mkdtempSync(path.join(tmpdir(), 'sro-backlog-order-')); - try { - const first = makeProvenance(); - const second = makeProvenance({ - upstreamTag: 'v0.2.1', - upstreamCommit: 'd'.repeat(40), - nativeReleaseTag: 'v0.2.1', - nativeCommit: 'e'.repeat(40), - nativeManifestSha256: 'f'.repeat(64), - nativeReleasePublishedAt: '2026-08-21T12:34:56Z', - }); - const firstCorrelation = runNames.computeCorrelationId(first); - const secondCorrelation = runNames.computeCorrelationId(second); - const firstBinding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.40', releaseRebuild: 0 }); - const secondBinding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.40-1', releaseRebuild: 1 }); - const firstRunId = '4101'; - const secondRunId = '4201'; - const secondQualificationRunId = '4202'; - const firstArtifactId = 31; - const secondArtifactId = 32; - const secondQualificationArtifactId = 33; - - const firstCandidate = path.join(root, 'first-candidate'); - const secondCandidate = path.join(root, 'second-candidate'); - writeBridgeCandidate(firstCandidate, { - releaseTag: firstBinding.releaseTag, releaseRebuild: firstBinding.releaseRebuild, correlationId: firstCorrelation, runId: firstRunId, - }); - writeBridgeCandidate(secondCandidate, { - releaseTag: secondBinding.releaseTag, - releaseRebuild: secondBinding.releaseRebuild, - correlationId: secondCorrelation, - runId: secondRunId, - upstreamTag: second.upstreamTag, - upstreamCommit: second.upstreamCommit, - nativeReleaseTag: second.nativeReleaseTag, - nativeManifestSha256: second.nativeManifestSha256, - nativeCommit: second.nativeCommit, - }); - const secondQualification = attestationBytes(secondCandidate, { - candidateRunId: secondRunId, - candidateArtifactId: secondArtifactId, - qualificationRunId: secondQualificationRunId, - }); - - const firstRun = runPayload({ - runId: firstRunId, path: model.CANDIDATE_WORKFLOW_PATH, runName: runNames.candidateRunName(firstCorrelation, firstBinding), - }); - const secondRun = runPayload({ - runId: secondRunId, path: model.CANDIDATE_WORKFLOW_PATH, runName: runNames.candidateRunName(secondCorrelation, secondBinding), - }); - const qualificationRun = runPayload({ - runId: secondQualificationRunId, - path: model.QUALIFICATION_WORKFLOW_PATH, - runName: runNames.qualificationRunName(secondCorrelation, secondRunId), - }); - const releases = [assetReleaseStub()]; - const routes = { - [`repos/${ASSETS_REPOSITORY}/releases?per_page=100`]: [releases], - [`repos/${BRIDGE_REPOSITORY}/compare/${BRIDGE_SHA}...${DEFAULT_BRANCH}`]: { status: 'ahead' }, - [`repos/${BRIDGE_REPOSITORY}/compare/${HEAD_SHA}...${DEFAULT_BRANCH}`]: { status: 'identical' }, - [`${RUNS}/${firstRunId}`]: firstRun, - [`${RUNS}/${secondRunId}`]: secondRun, - [`${RUNS}/${secondQualificationRunId}`]: qualificationRun, - [`${RUNS}/${firstRunId}/artifacts?per_page=100`]: artifactInventory({ - runId: firstRunId, name: CANDIDATE_ARTIFACT_NAME, artifactId: firstArtifactId, - }), - [`${RUNS}/${secondRunId}/artifacts?per_page=100`]: artifactInventory({ - runId: secondRunId, name: CANDIDATE_ARTIFACT_NAME, artifactId: secondArtifactId, - }), - [`${RUNS}/${secondQualificationRunId}/artifacts?per_page=100`]: artifactInventory({ - runId: secondQualificationRunId, name: ATTESTATION_ARTIFACT_NAME, artifactId: secondQualificationArtifactId, - }), - }; - const candidateRuns = runsResponse([firstRun, secondRun]); - const qualificationRuns = runsResponse([qualificationRun]); - const publishRuns = runsResponse([]); - for (const provenance of [first, second]) { - const since = assetReleases.workflowHistorySince(releases, provenance); - routes[runsKey(model.CANDIDATE_WORKFLOW_FILE, since)] = candidateRuns; - routes[runsKey(model.QUALIFICATION_WORKFLOW_FILE, since)] = qualificationRuns; - routes[runsKey(model.PUBLISH_WORKFLOW_FILE, since)] = publishRuns; - } - const gateway = new FakeGateway({ - jsonRoutes: routes, - blobRoutes: { - [`${ARTIFACTS}/${firstArtifactId}/zip`]: flatZip(directoryMembers(firstCandidate)), - [`${ARTIFACTS}/${secondArtifactId}/zip`]: flatZip(directoryMembers(secondCandidate)), - [`${ARTIFACTS}/${secondQualificationArtifactId}/zip`]: flatZip({ 'qualification-attestation.json': secondQualification }), - }, - }); - const firstQualificationRun = runPayload({ - runId: '4201', - path: model.QUALIFICATION_WORKFLOW_PATH, - runName: runNames.qualificationRunName(firstCorrelation, firstRunId), - status: 'in_progress', - conclusion: null, - }); - const qualificationRouteKeys = [first, second].map((provenance) => runsKey( - model.QUALIFICATION_WORKFLOW_FILE, - assetReleases.workflowHistorySince(releases, provenance), - )); - onDispatch(gateway, () => { - for (const key of qualificationRouteKeys) gateway.jsonRoutes[key] = runsResponse([qualificationRun, firstQualificationRun]); - }); - - const provenanceList = path.join(root, 'release-candidates.json'); - fs.writeFileSync(provenanceList, pyJsonDumps([orchestrator.provenanceToDict(first), orchestrator.provenanceToDict(second)]), 'utf8'); - const outputPlan = path.join(root, 'orchestration-plan.json'); - const result = orchestrator.main([ - 'orchestrate-backlog', - '--provenance-list-json', - provenanceList, - '--workspace', - path.join(root, 'workspace'), - '--output-plan-json', - outputPlan, - ], { - env: { - ...process.env, - GITHUB_EVENT_NAME: 'schedule', - GITHUB_ACTOR: 'github-actions', - GITHUB_TRIGGERING_ACTOR: 'github-actions', - }, - createGateway: () => gateway, - stdout: () => {}, - stderr: () => {}, - }); - assert.equal(result, 0); - const plan = pyJsonLoads(fs.readFileSync(outputPlan, 'utf8')); - assert.deepEqual(plan.plans.map((item) => item.action), [ - model.OrchestrationAction.DISPATCH_QUALIFICATION, - model.OrchestrationAction.WAITING_FOR_PRIOR_PUBLICATION, - ]); - // The later provenance is fully qualified but must not publish before the - // earlier one, so exactly one qualification dispatch happens and no - // publication is dispatched. - assert.deepEqual(gateway.dispatches.map((record) => record.workflowFile), [model.QUALIFICATION_WORKFLOW_FILE]); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -test('test_native_behind_the_published_alignment_is_superseded', withFixture((self) => { - const priorBuild = self.newerNative({ bridgeBuildSha: ADVANCED_BRIDGE_SHA }); - const gateway = new FakeGateway({ - jsonRoutes: self.routes({ releases: [assetReleaseStub(), alignedReleaseStub('v0.1.40', priorBuild)] }), - }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp, newerNativeScanned: true }); - assert.equal(plan.action, model.OrchestrationAction.SUPERSEDED); - assert.ok(plan.reason.includes('v0.2.1')); - assert.ok(plan.reason.includes('v0.1.40')); - assert.equal(plan.releaseTarget, null); - assert.deepEqual(gateway.dispatches, []); - assert.equal(gateway.apiPaths.some((apiPath) => apiPath.includes('/actions/')), false); -})); - -test('test_newest_scanned_native_is_never_skipped_as_superseded', withFixture(async (self) => { - const priorBuild = self.newerNative({ bridgeBuildSha: ADVANCED_BRIDGE_SHA }); - const gateway = new FakeGateway({ jsonRoutes: self.routes({ releases: [alignedReleaseStub('v0.1.40', priorBuild)] }) }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.BLOCKED); - assert.deepEqual(gateway.dispatches, []); - const [result, backlog] = await self.runBacklog(gateway, [self.provenance]); - assert.equal(result, 1); - assert.deepEqual(backlog.plans.map((item) => item.action), [model.OrchestrationAction.BLOCKED]); -})); - -test('test_newest_native_stays_eligible_after_a_governed_bridge_change', withFixture((self) => { - const priorBuild = replaceProvenance(self.provenance, { bridgeBuildSha: ADVANCED_BRIDGE_SHA }); - const gateway = new FakeGateway({ jsonRoutes: self.routes({ releases: [alignedReleaseStub('v0.1.40', priorBuild)] }) }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp, dryRun: true }); - assert.equal(plan.action, model.OrchestrationAction.DISPATCH_CANDIDATE); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.41'); -})); - -test('test_native_rebuild_ahead_of_the_alignment_stays_eligible', withFixture((self) => { - const rebuilt = makeProvenance({ nativeReleaseTag: 'v0.2.0-1' }); - const gateway = new FakeGateway({ jsonRoutes: self.routes({ releases: [alignedReleaseStub('v0.1.40', self.provenance)] }) }); - const plan = driver.advancePipeline(gateway, { provenance: rebuilt, workspace: self.tmp, dryRun: true }); - assert.equal(plan.action, model.OrchestrationAction.DISPATCH_CANDIDATE); -})); - -test('test_native_rebuild_behind_the_alignment_is_not_republished', withFixture((self) => { - const rebuilt = makeProvenance({ nativeReleaseTag: 'v0.2.0-1', bridgeBuildSha: ADVANCED_BRIDGE_SHA }); - for (const [newerNativeScanned, action] of [ - [true, model.OrchestrationAction.SUPERSEDED], - [false, model.OrchestrationAction.BLOCKED], - ]) { - const gateway = new FakeGateway({ jsonRoutes: self.routes({ releases: [alignedReleaseStub('v0.1.40', rebuilt)] }) }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp, newerNativeScanned }); - assert.equal(plan.action, action, `newerNativeScanned=${newerNativeScanned}`); - assert.ok(plan.reason.includes('v0.2.0-1')); - assert.deepEqual(gateway.dispatches, []); - } -})); - -test('test_first_publication_into_an_empty_assets_history', withFixture((self) => { - const gateway = new FakeGateway({ jsonRoutes: self.routes({ releases: [] }) }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp, dryRun: true }); - assert.equal(plan.action, model.OrchestrationAction.DISPATCH_CANDIDATE); - assert.equal(plan.releaseTarget.releaseTag, releaseTags.INITIAL_STABLE_RELEASE_TAG); -})); - -test('test_published_older_native_stays_a_verified_noop', withFixture((self) => { - const candidateDir = path.join(self.tmp, 'candidate'); - writeBridgeCandidate(candidateDir, { releaseTag: 'v0.1.40', releaseRebuild: 0, correlationId: self.correlationId }); - const gateway = publishedCandidateGateway(self, candidateDir, { provenance: self.provenance, correlationId: self.correlationId }); - gateway.jsonRoutes[`repos/${ASSETS_REPOSITORY}/releases?per_page=100`][0].push(alignedReleaseStub('v0.1.41', self.newerNative())); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp, newerNativeScanned: true }); - assert.equal(plan.action, model.OrchestrationAction.NOOP); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.40'); -})); - -test('test_backlog_skips_superseded_native_and_publishes_the_newest', withFixture(async (self) => { - const older = self.provenance; - const newest = self.newerNative(); - const correlation = runNames.computeCorrelationId(newest); - const binding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.41', releaseRebuild: 0 }); - const releases = [alignedReleaseStub('v0.1.40', replaceProvenance(newest, { bridgeBuildSha: ADVANCED_BRIDGE_SHA }))]; - const candidateDir = path.join(self.tmp, 'candidate-src'); - writeBridgeCandidate(candidateDir, { - releaseTag: binding.releaseTag, - releaseRebuild: binding.releaseRebuild, - correlationId: correlation, - upstreamTag: newest.upstreamTag, - upstreamCommit: newest.upstreamCommit, - nativeReleaseTag: newest.nativeReleaseTag, - nativeManifestSha256: newest.nativeManifestSha256, - nativeCommit: newest.nativeCommit, - }); - const attestation = attestationBytes(candidateDir); - const candidateRun = runPayload({ - runId: CANDIDATE_RUN_ID, path: model.CANDIDATE_WORKFLOW_PATH, runName: runNames.candidateRunName(correlation, binding), - }); - const qualificationRun = runPayload({ - runId: QUALIFICATION_RUN_ID, - path: model.QUALIFICATION_WORKFLOW_PATH, - runName: runNames.qualificationRunName(correlation, CANDIDATE_RUN_ID), - }); - const publishRun = runPayload({ - runId: '701', - path: model.PUBLISH_WORKFLOW_PATH, - runName: runNames.publishRunName(correlation, CANDIDATE_RUN_ID, QUALIFICATION_RUN_ID, binding), - status: 'in_progress', - conclusion: null, - }); - const routes = self.routes({ releases }); - for (const [workflowFile, recorded] of [ - [model.CANDIDATE_WORKFLOW_FILE, [candidateRun]], - [model.QUALIFICATION_WORKFLOW_FILE, [qualificationRun]], - [model.PUBLISH_WORKFLOW_FILE, []], - ]) { - routes[runsKey(workflowFile, assetReleases.workflowHistorySince(releases, newest))] = runsResponse(recorded); - } - const publishReadbackKey = runsKey(model.PUBLISH_WORKFLOW_FILE, newest.nativeReleasePublishedAt); - routes[publishReadbackKey] = runsResponse([]); - Object.assign(routes, { - [`${RUNS}/${CANDIDATE_RUN_ID}`]: candidateRun, - [`${RUNS}/${QUALIFICATION_RUN_ID}`]: qualificationRun, - [`${RUNS}/${CANDIDATE_RUN_ID}/artifacts?per_page=100`]: artifactInventory({ - runId: CANDIDATE_RUN_ID, name: CANDIDATE_ARTIFACT_NAME, artifactId: CANDIDATE_ARTIFACT_ID, - }), - [`${RUNS}/${QUALIFICATION_RUN_ID}/artifacts?per_page=100`]: artifactInventory({ - runId: QUALIFICATION_RUN_ID, name: ATTESTATION_ARTIFACT_NAME, artifactId: QUALIFICATION_ARTIFACT_ID, - }), - }); - const gateway = new FakeGateway({ - jsonRoutes: routes, - blobRoutes: { - [`${ARTIFACTS}/${CANDIDATE_ARTIFACT_ID}/zip`]: flatZip(directoryMembers(candidateDir)), - [`${ARTIFACTS}/${QUALIFICATION_ARTIFACT_ID}/zip`]: flatZip({ 'qualification-attestation.json': attestation }), - }, - }); - onDispatch(gateway, () => { - gateway.jsonRoutes[publishReadbackKey] = runsResponse([publishRun]); - }); - - const [result, backlog] = await self.runBacklog(gateway, [newest, older]); - assert.equal(result, 0); - assert.deepEqual(backlog.errors, []); - assert.deepEqual(backlog.plans.map((item) => [item.provenance.native_release_tag, item.action]), [ - ['v0.2.0', model.OrchestrationAction.SUPERSEDED], - ['v0.2.1', model.OrchestrationAction.DISPATCH_PUBLISH], - ]); - assert.deepEqual( - gateway.dispatches.map((record) => [record.workflowFile, record.inputs.release_tag]), - [[model.PUBLISH_WORKFLOW_FILE, 'v0.1.41']], - ); -})); - -test('test_backlog_dry_run_reserves_an_earlier_planned_output_tag', withFixture((self) => { - const gateway = new FakeGateway({ jsonRoutes: self.routes({ releases: [assetReleaseStub()] }) }); - const plan = driver.advancePipeline(gateway, { - provenance: self.provenance, workspace: self.tmp, dryRun: true, reservedReleaseTags: new Set(['v0.1.40']), - }); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.41'); -})); - -test('test_published_release_is_a_noop_with_no_dispatch', withFixture((self) => { - const candidateDir = path.join(self.tmp, 'candidate'); - writeBridgeCandidate(candidateDir, { releaseTag: 'v0.1.40', releaseRebuild: 0, correlationId: self.correlationId }); - const gateway = publishedCandidateGateway(self, candidateDir, { provenance: self.provenance, correlationId: self.correlationId }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.NOOP); - assert.deepEqual(gateway.dispatches, []); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.40'); -})); - -// A legacy v0.1.40 candidate directory with the historical manifest. -function legacyCandidate(self, name, correlationId) { - const candidateDir = path.join(self.tmp, name); - writeBridgeCandidate(candidateDir, { - releaseTag: 'v0.1.40', - releaseRebuild: 0, - correlationId, - bridgeCommit: LEGACY_BRIDGE_SHA, - runId: LEGACY_CANDIDATE_RUN_ID, - upstreamTag: 'v0.3.0', - upstreamCommit: LEGACY_UPSTREAM_COMMIT, - nativeReleaseTag: 'v0.3.0', - nativeManifestSha256: LEGACY_NATIVE_MANIFEST_SHA, - nativeCommit: LEGACY_NATIVE_COMMIT, - }); - rewriteLegacyCandidateManifest(candidateDir); - return candidateDir; -} - -test('test_legacy_v0140_publication_is_a_terminal_noop', withFixture((self) => { - const provenance = makeLegacyV0140Provenance({ bridgeSourceSha: ADVANCED_BRIDGE_SHA }); - const correlationId = runNames.computeCorrelationId(provenance); - const candidateDir = legacyCandidate(self, 'legacy-v0140-candidate', correlationId); - // The ordinary candidate path remains strict. Compatibility is selected - // only after the immutable published-release identity is proven. - assert.throws(() => loadCandidate(candidateDir), ContractError); - const gateway = publishedCandidateGateway(self, candidateDir, { - provenance, - correlationId, - publishedManifestCompatibility: [LEGACY_MANUAL_QUALIFICATION_GATES, LEGACY_MANUAL_UNPROVEN_CAPABILITIES], - }); - const plan = driver.advancePipeline(gateway, { provenance, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.NOOP); - assert.deepEqual(gateway.dispatches, []); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.40'); -})); - -test('test_governed_source_change_does_not_reuse_legacy_v0140', withFixture((self) => { - const legacy = makeLegacyV0140Provenance({ bridgeSourceSha: ADVANCED_BRIDGE_SHA }); - const legacyCorrelation = runNames.computeCorrelationId(legacy); - const candidateDir = legacyCandidate(self, 'legacy-v0140-before-governed-change', legacyCorrelation); - const gateway = publishedCandidateGateway(self, candidateDir, { - provenance: legacy, - correlationId: legacyCorrelation, - publishedManifestCompatibility: [LEGACY_MANUAL_QUALIFICATION_GATES, LEGACY_MANUAL_UNPROVEN_CAPABILITIES], - }); - const governed = makeLegacyV0140Provenance({ bridgeSourceSha: ADVANCED_BRIDGE_SHA, bridgeBuildSha: ADVANCED_BRIDGE_SHA }); - gateway.jsonRoutes[runsKey(model.CANDIDATE_WORKFLOW_FILE, '2026-08-20T03:24:11Z')] = runsResponse([]); - const plan = driver.advancePipeline(gateway, { - provenance: governed, workspace: path.join(self.tmp, 'governed-change'), dryRun: true, - }); - assert.equal(plan.action, model.OrchestrationAction.DISPATCH_CANDIDATE); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.41'); - assert.equal(plan.dispatchInputs.bridge_source_sha, ADVANCED_BRIDGE_SHA); - assert.deepEqual(gateway.dispatches, []); -})); - -test('test_two_releases_claiming_one_correlation_fail_closed', withFixture((self) => { - const body = `Orchestrator correlation: \`${self.correlationId}\``; - const releases = [ - { - tag_name: 'v0.1.40', draft: false, prerelease: false, body, - }, - { - tag_name: 'v0.1.41', draft: false, prerelease: true, body, - }, - ]; - const gateway = new FakeGateway({ jsonRoutes: self.routes({ releases }) }); - assert.throws(() => driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }), ContractError); -})); diff --git a/tests/release/orchestrator/driver_fixtures.mjs b/tests/release/orchestrator/driver_fixtures.mjs deleted file mode 100644 index 0691d4d..0000000 --- a/tests/release/orchestrator/driver_fixtures.mjs +++ /dev/null @@ -1,107 +0,0 @@ -// Node-only helpers the driver and CLI suites share, for idioms the Python -// suites write inline: a run-listing route key, wrapping the fake gateway's -// dispatch (Python reassigns gateway.dispatch_workflow), dataclasses.replace -// of a provenance, the hosted qualification attestation the Python suites -// build with release_qualification_test's fixtures, and a verifiable -// published release. - -import { createHash } from 'node:crypto'; -import path from 'node:path'; - -import { ASSETS_REPOSITORY } from '../../../scripts/release/contract.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as workflowRuns from '../../../scripts/release/orchestrator/workflow_runs.mjs'; -import { buildAttestation, canonicalJson, harnessSourceSha256, loadCandidate } from '../../../scripts/release/qualification.mjs'; -import { - qualificationEnvironment, qualificationIdentity, speechPhase, ttsPhase, -} from '../qualification_fixtures.mjs'; -import { releaseAttestation } from '../contract_fixtures.mjs'; -import { - ASSETS_TAG_COMMIT, CANDIDATE_ARTIFACT_ID, CANDIDATE_RUN_ID, DEFAULT_BRANCH, HEAD_SHA, NATIVE_PUBLISHED_AT, - QUALIFICATION_RUN_ID, directoryMembers, provenanceToDict, releasePayload, -} from './fixtures.mjs'; - -// The scripts/ directory, whose harness sources the attestation digests -// (Python's Path(__file__).resolve().parent). -export const SCRIPTS_DIR = path.resolve(import.meta.dirname, '..', '..', '..', 'scripts'); - -// workflow_runs._workflow_runs_path(workflow_file=..., default_branch=main, -// created_since=...). -export function runsKey(workflowFile, createdSince = NATIVE_PUBLISHED_AT) { - return workflowRuns.workflowRunsPath({ workflowFile, defaultBranch: DEFAULT_BRANCH, createdSince }); -} - -// Wrap gateway.dispatchWorkflow so each dispatch is recorded, then runs -// after(options). -export function onDispatch(gateway, after) { - const original = gateway.dispatchWorkflow.bind(gateway); - gateway.dispatchWorkflow = (options) => { - original(options); - after(options); - }; -} - -// dataclasses.replace(provenance, **overrides) with camelCase keys. -export function replaceProvenance(provenance, overrides) { - const fields = Object.fromEntries(Object.entries(provenanceToDict(provenance)) - .map(([key, value]) => [key.replace(/_([a-z0-9])/gu, (_, char) => char.toUpperCase()), value])); - return new model.NativeProvenance({ ...fields, ...overrides }); -} - -// rq.build_attestation(...) of the candidate in `candidateDir`, qualified by -// `qualificationRunId` from HEAD_SHA, as canonical JSON bytes. -export function attestationBytes(candidateDir, { - candidateRunId = CANDIDATE_RUN_ID, - candidateArtifactId = CANDIDATE_ARTIFACT_ID, - qualificationRunId = QUALIFICATION_RUN_ID, - manifestDir = candidateDir, -} = {}) { - const [manifest, fingerprint] = loadCandidate(manifestDir); - const attestation = buildAttestation({ - manifest, - candidateFingerprint: fingerprint, - candidateRunId, - candidateArtifactId, - candidateRunAttempt: 1, - ...qualificationIdentity({ qualificationRunId, qualificationSourceSha: HEAD_SHA }), - harnessDigest: harnessSourceSha256(SCRIPTS_DIR), - environment: qualificationEnvironment(), - speechPhase: speechPhase(), - ttsPhase: ttsPhase(), - }); - return Buffer.from(canonicalJson(attestation), 'utf8'); -} - -// _aligned_release: [release, routes, blobs, attestations], a verifiable -// immutable release carrying the deterministic notes. -export function alignedRelease(publishedDir, { - tag, provenance, correlationId, releaseId = 4343, -}) { - const members = directoryMembers(publishedDir); - const fingerprint = loadCandidate(publishedDir)[1]; - const body = ( - `Candidate fingerprint: \`${fingerprint}\`\n` - + `Native: \`${provenance.nativeRepo}@${provenance.nativeReleaseTag}\`\n` - + `Native manifest SHA-256: \`${provenance.nativeManifestSha256}\`\n` - + `Orchestrator correlation: \`${correlationId}\`\n` - ); - const release = releasePayload({ - tag, body, members, releaseId, - }); - const routes = { - [`repos/${ASSETS_REPOSITORY}/git/ref/tags/${tag}`]: { ref: `refs/tags/${tag}`, object: { type: 'commit', sha: ASSETS_TAG_COMMIT } }, - [`repos/${ASSETS_REPOSITORY}/releases/tags/${tag}`]: release, - [`repos/${ASSETS_REPOSITORY}/releases/${releaseId}`]: release, - }; - const blobs = Object.fromEntries(release.assets.map((asset) => [ - `repos/${ASSETS_REPOSITORY}/releases/assets/${asset.id}`, members[asset.name], - ])); - const attestations = [[[ASSETS_REPOSITORY, tag], releaseAttestation({ - releaseTag: tag, - assetsRepo: ASSETS_REPOSITORY, - tagCommit: ASSETS_TAG_COMMIT, - releaseId, - assets: Object.fromEntries(Object.entries(members).map(([name, data]) => [name, createHash('sha256').update(data).digest('hex')])), - })]]; - return [release, routes, blobs, attestations]; -} diff --git a/tests/release/orchestrator/driver_identical_release_test.mjs b/tests/release/orchestrator/driver_identical_release_test.mjs deleted file mode 100644 index 148d3f4..0000000 --- a/tests/release/orchestrator/driver_identical_release_test.mjs +++ /dev/null @@ -1,319 +0,0 @@ -// Tests of candidates satisfied by an identical published release, one test -// per test method of scripts/release_orchestrator_driver_identical_release_test.py, -// with the same names and assertions. A Python subTest loop is one loop inside -// its test. - -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { test } from 'node:test'; - -import { ASSETS_REPOSITORY, BRIDGE_REPOSITORY } from '../../../scripts/release/contract.mjs'; -import * as orchestrator from '../../../scripts/release/orchestrator/cli.mjs'; -import * as assetReleases from '../../../scripts/release/orchestrator/asset_releases.mjs'; -import * as driver from '../../../scripts/release/orchestrator/driver.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as runNames from '../../../scripts/release/orchestrator/run_names.mjs'; -import { ATTESTATION_ARTIFACT_NAME, CANDIDATE_ARTIFACT_NAME } from '../../../scripts/release/qualification.mjs'; -import { - ADVANCED_BRIDGE_SHA, BRIDGE_SHA, CANDIDATE_ARTIFACT_ID, CANDIDATE_RUN_ID, FakeGateway, - NATIVE_PUBLISHED_AT, QUALIFICATION_ARTIFACT_ID, QUALIFICATION_RUN_ID, artifactInventory, directoryMembers, flatZip, - makeProvenance, runPayload, runsResponse, withAdvancePipelineFixture, writeBridgeCandidate, -} from './fixtures.mjs'; -import { - alignedRelease, attestationBytes, onDispatch, replaceProvenance, runsKey, -} from './driver_fixtures.mjs'; - -const RUNS = `repos/${BRIDGE_REPOSITORY}/actions/runs`; - -function withFixture(fn) { - return withAdvancePipelineFixture(fn, { orchestrator }); -} - -// _proven_candidate: [run, routes, blobs], a succeeded candidate run whose -// artifact is downloadable and proven. -function provenCandidate(candidateDir, { marker, releaseTag, provenance }) { - const correlationId = runNames.computeCorrelationId(provenance); - writeBridgeCandidate(candidateDir, { - releaseTag, - releaseRebuild: 0, - correlationId, - runId: CANDIDATE_RUN_ID, - marker: Buffer.from(marker), - upstreamTag: provenance.upstreamTag, - upstreamCommit: provenance.upstreamCommit, - nativeReleaseTag: provenance.nativeReleaseTag, - nativeManifestSha256: provenance.nativeManifestSha256, - nativeCommit: provenance.nativeCommit, - }); - const run = runPayload({ - runId: CANDIDATE_RUN_ID, - path: model.CANDIDATE_WORKFLOW_PATH, - runName: runNames.candidateRunName( - correlationId, - new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag, releaseRebuild: 0 }), - ), - }); - const routes = { - [`${RUNS}/${CANDIDATE_RUN_ID}`]: run, - [`${RUNS}/${CANDIDATE_RUN_ID}/artifacts?per_page=100`]: artifactInventory({ - runId: CANDIDATE_RUN_ID, name: CANDIDATE_ARTIFACT_NAME, artifactId: CANDIDATE_ARTIFACT_ID, - }), - }; - const blobs = { - [`repos/${BRIDGE_REPOSITORY}/actions/artifacts/${CANDIDATE_ARTIFACT_ID}/zip`]: flatZip(directoryMembers(candidateDir)), - }; - return [run, routes, blobs]; -} - -// _aligned_candidate_gateway: v0.1.41 publishes "same" bytes for native v0.2.0 -// under an earlier build identity; the scanned provenance has a proven -// candidate bound to v0.1.42. Returns [gateway, release, run]. -function alignedCandidateGateway(self, { candidateMarker, provenance = null, siblingRuns = null }) { - const scanned = provenance || self.provenance; - const prior = replaceProvenance(self.provenance, { bridgeSourceSha: ADVANCED_BRIDGE_SHA, bridgeBuildSha: ADVANCED_BRIDGE_SHA }); - const priorCorrelation = runNames.computeCorrelationId(prior); - const publishedDir = path.join(self.tmp, 'published-src'); - writeBridgeCandidate(publishedDir, { - releaseTag: 'v0.1.41', - releaseRebuild: 0, - correlationId: priorCorrelation, - bridgeCommit: ADVANCED_BRIDGE_SHA, - runId: '777', - marker: Buffer.from('same'), - }); - const [release, releaseRoutes, releaseBlobs, attestations] = alignedRelease(publishedDir, { - tag: 'v0.1.41', provenance: prior, correlationId: priorCorrelation, - }); - const [run, runRoutes, runBlobs] = provenCandidate(path.join(self.tmp, 'candidate-src'), { - marker: candidateMarker, releaseTag: 'v0.1.42', provenance: scanned, - }); - const routes = { ...self.routes({ releases: [release], candidateRuns: [run, ...(siblingRuns || [])] }), ...releaseRoutes, ...runRoutes }; - const gateway = new FakeGateway({ - jsonRoutes: routes, - blobRoutes: { ...releaseBlobs, ...runBlobs }, - releaseAttestations: attestations, - }); - return [gateway, release, run]; -} - -// _expect_qualification_dispatch. -function expectQualificationDispatch(gateway, provenance) { - const correlationId = runNames.computeCorrelationId(provenance); - const readbackKey = runsKey(model.QUALIFICATION_WORKFLOW_FILE); - const dispatched = runPayload({ - runId: '4201', - path: model.QUALIFICATION_WORKFLOW_PATH, - runName: runNames.qualificationRunName(correlationId, CANDIDATE_RUN_ID), - status: 'in_progress', - conclusion: null, - }); - onDispatch(gateway, () => { - gateway.jsonRoutes[readbackKey] = runsResponse([dispatched]); - }); -} - -const touchedRelease = (gateway, release) => gateway.apiPaths.some((apiPath) => apiPath.includes(`/releases/${release.id}`)); - -test('test_identical_candidate_is_satisfied_by_the_aligned_release', withFixture((self) => { - const [gateway, release] = alignedCandidateGateway(self, { candidateMarker: 'same' }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.SATISFIED_BY_IDENTICAL_RELEASE); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.41'); - assert.equal(plan.candidateRunId, CANDIDATE_RUN_ID); - assert.equal(plan.dispatchWorkflow, null); - assert.ok(plan.reason.includes('v0.1.42')); - assert.deepEqual(gateway.dispatches, []); - assert.ok(gateway.apiPaths.includes(`repos/${ASSETS_REPOSITORY}/releases/${release.id}`)); - assert.equal(plan.qualificationRunId, null); - assert.ok(plan.reason.includes('other than manifest.json')); - assert.equal(plan.toDict().action, 'satisfied_by_identical_release'); -})); - -// _qualify_candidate: record a succeeded, attested qualification of the -// aligned fixture's candidate and the given publication runs. -function qualifyCandidate(self, gateway, { publishRuns = [] } = {}) { - const bytes = attestationBytes(path.join(self.tmp, 'candidate-src')); - const qualificationRun = runPayload({ - runId: QUALIFICATION_RUN_ID, - path: model.QUALIFICATION_WORKFLOW_PATH, - runName: runNames.qualificationRunName(self.correlationId, CANDIDATE_RUN_ID), - }); - gateway.jsonRoutes[`${RUNS}/${QUALIFICATION_RUN_ID}`] = qualificationRun; - gateway.jsonRoutes[`${RUNS}/${QUALIFICATION_RUN_ID}/artifacts?per_page=100`] = artifactInventory({ - runId: QUALIFICATION_RUN_ID, name: ATTESTATION_ARTIFACT_NAME, artifactId: QUALIFICATION_ARTIFACT_ID, - }); - for (const [workflowFile, listed] of [ - [model.QUALIFICATION_WORKFLOW_FILE, [qualificationRun]], - [model.PUBLISH_WORKFLOW_FILE, [...publishRuns]], - ]) { - gateway.jsonRoutes[runsKey(workflowFile)] = runsResponse(listed); - } - gateway.blobRoutes[`repos/${BRIDGE_REPOSITORY}/actions/artifacts/${QUALIFICATION_ARTIFACT_ID}/zip`] = flatZip({ - 'qualification-attestation.json': bytes, - }); -} - -// _publish_run. -function publishRun(self, { status, conclusion }) { - return runPayload({ - runId: '701', - path: model.PUBLISH_WORKFLOW_PATH, - runName: runNames.publishRunName( - self.correlationId, - CANDIDATE_RUN_ID, - QUALIFICATION_RUN_ID, - new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.42', releaseRebuild: 0 }), - ), - status, - conclusion, - }); -} - -test('test_qualified_identical_candidate_is_satisfied_before_publication', withFixture((self) => { - const [gateway, release] = alignedCandidateGateway(self, { candidateMarker: 'same' }); - qualifyCandidate(self, gateway); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.SATISFIED_BY_IDENTICAL_RELEASE); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.41'); - assert.equal(plan.qualificationRunId, QUALIFICATION_RUN_ID); - assert.deepEqual(gateway.dispatches, []); - assert.ok(gateway.apiPaths.includes(`repos/${ASSETS_REPOSITORY}/releases/${release.id}`)); -})); - -test('test_failed_publication_of_an_identical_candidate_is_satisfied_not_retried', withFixture((self) => { - const [gateway] = alignedCandidateGateway(self, { candidateMarker: 'same' }); - qualifyCandidate(self, gateway, { publishRuns: [publishRun(self, { status: 'completed', conclusion: 'failure' })] }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.SATISFIED_BY_IDENTICAL_RELEASE); - assert.deepEqual(gateway.dispatches, []); -})); - -test('test_identical_candidate_with_qualification_in_flight_is_not_compared', withFixture((self) => { - const [gateway, release] = alignedCandidateGateway(self, { candidateMarker: 'same' }); - gateway.jsonRoutes[runsKey(model.QUALIFICATION_WORKFLOW_FILE)] = runsResponse([runPayload({ - runId: '4201', - path: model.QUALIFICATION_WORKFLOW_PATH, - runName: runNames.qualificationRunName(self.correlationId, CANDIDATE_RUN_ID), - status: 'in_progress', - conclusion: null, - })]); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.IN_FLIGHT); - assert.equal(plan.inFlightWorkflow, model.QUALIFICATION_WORKFLOW_FILE); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.42'); - assert.equal(touchedRelease(gateway, release), false); -})); - -// _route_newer_native: route a backlog scan of native v0.2.1 that sees `run` -// as the only candidate and lists the candidate it dispatches on readback. -function routeNewerNative(self, gateway, release, run) { - const newer = self.newerNative(); - const newerSince = assetReleases.workflowHistorySince([release], newer); - assert.notEqual(newerSince, NATIVE_PUBLISHED_AT); - for (const workflowFile of [model.CANDIDATE_WORKFLOW_FILE, model.QUALIFICATION_WORKFLOW_FILE, model.PUBLISH_WORKFLOW_FILE]) { - gateway.jsonRoutes[runsKey(workflowFile, newerSince)] = runsResponse(workflowFile === model.CANDIDATE_WORKFLOW_FILE ? [run] : []); - } - const readbackKey = runsKey(model.CANDIDATE_WORKFLOW_FILE, newer.nativeReleasePublishedAt); - gateway.jsonRoutes[readbackKey] = runsResponse([run]); - onDispatch(gateway, ({ inputs }) => { - gateway.jsonRoutes[readbackKey] = runsResponse([ - run, - runPayload({ - runId: '501', - path: model.CANDIDATE_WORKFLOW_PATH, - runName: ( - `bridge-candidate ${runNames.computeCorrelationId(newer)}` - + ` source:${BRIDGE_SHA}` - + ` tag:${inputs.release_tag}` - + ` rebuild:${inputs.release_rebuild}` - ), - status: 'in_progress', - conclusion: null, - }), - ]); - }); - return newer; -} - -const backlogRows = (backlog) => backlog.plans.map((item) => [item.provenance.native_release_tag, item.action, item.release_tag]); -const dispatchRows = (gateway) => gateway.dispatches.map((record) => [ - record.workflowFile, record.inputs.release_tag, record.inputs.release_rebuild, -]); - -test('test_identical_candidate_with_publication_in_flight_keeps_its_tag', withFixture(async (self) => { - const [gateway, release, run] = alignedCandidateGateway(self, { candidateMarker: 'same' }); - qualifyCandidate(self, gateway, { publishRuns: [publishRun(self, { status: 'in_progress', conclusion: null })] }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.IN_FLIGHT); - assert.equal(plan.inFlightWorkflow, model.PUBLISH_WORKFLOW_FILE); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.42'); - assert.equal(touchedRelease(gateway, release), false); - - const newer = routeNewerNative(self, gateway, release, run); - const [result, backlog] = await self.runBacklog(gateway, [self.provenance, newer]); - assert.deepEqual(backlog.errors, []); - assert.equal(result, 0); - assert.deepEqual(backlogRows(backlog), [ - ['v0.2.0', 'in_flight', 'v0.1.42'], - ['v0.2.1', 'dispatch_candidate', 'v0.1.43'], - ]); - assert.deepEqual(dispatchRows(gateway), [[model.CANDIDATE_WORKFLOW_FILE, 'v0.1.43', '0']]); -})); - -test('test_differing_candidate_is_qualified_not_skipped', withFixture((self) => { - const [gateway] = alignedCandidateGateway(self, { candidateMarker: 'changed' }); - expectQualificationDispatch(gateway, self.provenance); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.DISPATCH_QUALIFICATION); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.42'); - assert.deepEqual(gateway.dispatches.map((record) => record.workflowFile), [model.QUALIFICATION_WORKFLOW_FILE]); -})); - -test('test_identical_bytes_under_another_native_alignment_are_not_compared', withFixture((self) => { - const rebuilt = makeProvenance({ nativeReleaseTag: 'v0.2.0-1' }); - const [gateway, release] = alignedCandidateGateway(self, { candidateMarker: 'same', provenance: rebuilt }); - expectQualificationDispatch(gateway, rebuilt); - const plan = driver.advancePipeline(gateway, { provenance: rebuilt, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.DISPATCH_QUALIFICATION); - assert.equal(touchedRelease(gateway, release), false); - assert.deepEqual(gateway.dispatches.map((record) => record.workflowFile), [model.QUALIFICATION_WORKFLOW_FILE]); -})); - -test('test_satisfied_correlation_stays_satisfied_and_frees_its_tag_on_later_scans', withFixture(async (self) => { - const [gateway, release, run] = alignedCandidateGateway(self, { candidateMarker: 'same' }); - for (const scan of [1, 2]) { - const workspace = path.join(self.tmp, `scan-${scan}`); - fs.mkdirSync(workspace); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace }); - assert.equal(plan.action, model.OrchestrationAction.SATISFIED_BY_IDENTICAL_RELEASE, `scan ${scan}`); - } - assert.deepEqual(gateway.dispatches, []); - assert.equal(gateway.apiPaths.some((apiPath) => apiPath.includes(model.PUBLISH_WORKFLOW_FILE)), false); - - const newer = routeNewerNative(self, gateway, release, run); - const [result, backlog] = await self.runBacklog(gateway, [self.provenance, newer]); - assert.deepEqual(backlog.errors, []); - assert.equal(result, 0); - assert.deepEqual(backlogRows(backlog), [ - ['v0.2.0', 'satisfied_by_identical_release', 'v0.1.41'], - ['v0.2.1', 'dispatch_candidate', 'v0.1.42'], - ]); - assert.deepEqual(dispatchRows(gateway), [[model.CANDIDATE_WORKFLOW_FILE, 'v0.1.42', '0']]); -})); - -test('test_identical_candidate_still_publishes_when_a_sibling_claims_its_rebuild', withFixture((self) => { - const sibling = runPayload({ - runId: '502', - path: model.CANDIDATE_WORKFLOW_PATH, - runName: runNames.candidateRunName( - runNames.computeCorrelationId(self.newerNative()), - new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.42-1', releaseRebuild: 1 }), - ), - }); - const [gateway, release] = alignedCandidateGateway(self, { candidateMarker: 'same', siblingRuns: [sibling] }); - expectQualificationDispatch(gateway, self.provenance); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.DISPATCH_QUALIFICATION); - assert.equal(touchedRelease(gateway, release), false); -})); diff --git a/tests/release/orchestrator/driver_native_order_test.mjs b/tests/release/orchestrator/driver_native_order_test.mjs deleted file mode 100644 index 0bc1341..0000000 --- a/tests/release/orchestrator/driver_native_order_test.mjs +++ /dev/null @@ -1,858 +0,0 @@ -// Tests that output-tag order and publication order agree when several stable -// native releases share one scan: an older native never starts a pipeline -// behind a newer scanned native, an older pipeline holding a higher output tag -// than a newer native's is superseded, and a pipeline whose output tag a -// published release overtook is satisfied, superseded or replaced by a -// recovery pipeline instead of retried. - -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import process from 'node:process'; -import { test } from 'node:test'; - -import { - ASSETS_REPOSITORY, BRIDGE_REPOSITORY, NATIVE_REPOSITORY, parseReleaseTag, -} from '../../../scripts/release/contract.mjs'; -import { pyJsonDumps, pyJsonLoads } from '../../../scripts/release/json.mjs'; -import * as orchestrator from '../../../scripts/release/orchestrator/cli.mjs'; -import * as assetReleases from '../../../scripts/release/orchestrator/asset_releases.mjs'; -import * as driver from '../../../scripts/release/orchestrator/driver.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as runNames from '../../../scripts/release/orchestrator/run_names.mjs'; -import * as workflowRuns from '../../../scripts/release/orchestrator/workflow_runs.mjs'; -import { ATTESTATION_ARTIFACT_NAME, CANDIDATE_ARTIFACT_NAME } from '../../../scripts/release/qualification.mjs'; -import { - ADVANCED_BRIDGE_SHA, BRIDGE_SHA, FakeGateway, alignedReleaseStub, artifactInventory, assetReleaseStub, directoryMembers, - flatZip, provenanceToDict, runPayload, runsResponse, withAdvancePipelineFixture, writeBridgeCandidate, -} from './fixtures.mjs'; -import { - alignedRelease, attestationBytes, replaceProvenance, runsKey, -} from './driver_fixtures.mjs'; - -const RUNS = `repos/${BRIDGE_REPOSITORY}/actions/runs`; -const ARTIFACTS = `repos/${BRIDGE_REPOSITORY}/actions/artifacts`; -const { OrchestrationAction } = model; - -// Run identities of the older (v0.2.0) and newer (v0.2.1) native's pipelines. -const OLDER_IDS = Object.freeze({ - candidate: '5101', candidateArtifact: 51, qualification: '5102', qualificationArtifact: 52, publish: '5103', -}); -const NEWER_IDS = Object.freeze({ - candidate: '5201', candidateArtifact: 61, qualification: '5202', qualificationArtifact: 62, publish: '5203', -}); - -// A pipeline of `provenance` at the fixture's build identity and `recovery` -// generation bound to `releaseTag`: a succeeded candidate whose artifact -// carries `marker` bytes, a succeeded attested qualification when -// `qualified`, and a failed publication when `publishFailed`. -function pipeline(self, { - provenance, releaseTag, ids, marker = 'same', qualified = true, publishFailed = false, recovery = 0, -}) { - const correlationId = runNames.computeCorrelationId(provenance, { recovery }); - const binding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag, releaseRebuild: 0 }); - const directory = path.join(self.tmp, `candidate-${correlationId}`); - writeBridgeCandidate(directory, { - releaseTag, - releaseRebuild: 0, - correlationId, - runId: ids.candidate, - marker: Buffer.from(marker), - upstreamTag: provenance.upstreamTag, - upstreamCommit: provenance.upstreamCommit, - nativeReleaseTag: provenance.nativeReleaseTag, - nativeManifestSha256: provenance.nativeManifestSha256, - nativeCommit: provenance.nativeCommit, - }); - const candidateRun = runPayload({ - runId: ids.candidate, path: model.CANDIDATE_WORKFLOW_PATH, runName: runNames.candidateRunName(correlationId, binding), - }); - const routes = { - [`${RUNS}/${ids.candidate}`]: candidateRun, - [`${RUNS}/${ids.candidate}/artifacts?per_page=100`]: artifactInventory({ - runId: ids.candidate, name: CANDIDATE_ARTIFACT_NAME, artifactId: ids.candidateArtifact, - }), - }; - const blobs = { [`${ARTIFACTS}/${ids.candidateArtifact}/zip`]: flatZip(directoryMembers(directory)) }; - const runs = { candidate: [candidateRun], qualification: [], publish: [] }; - if (qualified) { - const qualificationRun = runPayload({ - runId: ids.qualification, - path: model.QUALIFICATION_WORKFLOW_PATH, - runName: runNames.qualificationRunName(correlationId, ids.candidate), - }); - routes[`${RUNS}/${ids.qualification}`] = qualificationRun; - routes[`${RUNS}/${ids.qualification}/artifacts?per_page=100`] = artifactInventory({ - runId: ids.qualification, name: ATTESTATION_ARTIFACT_NAME, artifactId: ids.qualificationArtifact, - }); - blobs[`${ARTIFACTS}/${ids.qualificationArtifact}/zip`] = flatZip({ - 'qualification-attestation.json': attestationBytes(directory, { - candidateRunId: ids.candidate, candidateArtifactId: ids.candidateArtifact, qualificationRunId: ids.qualification, - }), - }); - runs.qualification.push(qualificationRun); - } - if (publishFailed) { - runs.publish.push(runPayload({ - runId: ids.publish, - path: model.PUBLISH_WORKFLOW_PATH, - runName: runNames.publishRunName(correlationId, ids.candidate, ids.qualification, binding), - conclusion: 'failure', - })); - } - return { routes, blobs, runs }; -} - -// The native repository's release record of `provenance`, as its listing -// shows it: a draft when `draft`, its assets.json carrying `manifestSha256`. -function nativeRelease(provenance, { draft = false, manifestSha256 = provenance.nativeManifestSha256 } = {}) { - return { - tag_name: provenance.nativeReleaseTag, - draft, - prerelease: parseReleaseTag(provenance.nativeReleaseTag).githubPrerelease, - published_at: provenance.nativeReleasePublishedAt, - assets: [{ name: 'assets.json', digest: `sha256:${manifestSha256}` }], - }; -} - -// A gateway listing `releases` and every pipeline's runs to each provenance's -// run-history window, and `nativeReleases` as the native repository's. -function scanGateway(self, { - releases, provenances, pipelines = [], releaseRoutes = {}, releaseBlobs = {}, attestations = [], nativeReleases = [], -}) { - const routes = { - ...self.routes({ releases }), - ...releaseRoutes, - [`repos/${NATIVE_REPOSITORY}/releases?per_page=100`]: [nativeReleases], - }; - const blobs = { ...releaseBlobs }; - const listed = { candidate: [], qualification: [], publish: [] }; - for (const item of pipelines) { - Object.assign(routes, item.routes); - Object.assign(blobs, item.blobs); - for (const stage of Object.keys(listed)) listed[stage].push(...item.runs[stage]); - } - for (const provenance of provenances) { - const since = assetReleases.workflowHistorySince(releases, provenance); - routes[runsKey(model.CANDIDATE_WORKFLOW_FILE, since)] = runsResponse(listed.candidate); - routes[runsKey(model.QUALIFICATION_WORKFLOW_FILE, since)] = runsResponse(listed.qualification); - routes[runsKey(model.PUBLISH_WORKFLOW_FILE, since)] = runsResponse(listed.publish); - } - return new FakeGateway({ jsonRoutes: routes, blobRoutes: blobs, releaseAttestations: attestations }); -} - -// `orchestrate-backlog --dry-run` over `provenances`: [exit status, plan JSON]. -async function scanBacklog(self, gateway, provenances) { - const provenanceList = path.join(self.tmp, 'release-candidates.json'); - fs.writeFileSync(provenanceList, pyJsonDumps(provenances.map(provenanceToDict)), 'utf8'); - const outputPlan = path.join(self.tmp, 'orchestration-plan.json'); - const result = await orchestrator.main([ - 'orchestrate-backlog', - '--provenance-list-json', provenanceList, - '--workspace', fs.mkdtempSync(path.join(self.tmp, 'workspace-')), - '--output-plan-json', outputPlan, - '--dry-run', - ], { - env: { - ...process.env, GITHUB_EVENT_NAME: 'schedule', GITHUB_ACTOR: 'github-actions', GITHUB_TRIGGERING_ACTOR: 'github-actions', - }, - createGateway: () => gateway, - stdout: () => {}, - stderr: () => {}, - }); - return [result, pyJsonLoads(fs.readFileSync(outputPlan, 'utf8'))]; -} - -const rows = (backlog) => backlog.plans.map((plan) => [plan.provenance.native_release_tag, plan.action, plan.release_tag]); -const dispatched = (backlog) => backlog.plans - .filter((plan) => plan.dispatch_workflow !== null) - .map((plan) => [plan.dispatch_workflow, plan.dispatch_inputs.release_tag]); - -// The older native's alignment, published under an earlier build identity. -function priorAlignment(self) { - return alignedReleaseStub('v0.1.40', replaceProvenance(self.provenance, { bridgeBuildSha: ADVANCED_BRIDGE_SHA })); -} - -// `provenance` (by default the older native) published at this build -// identity and `recovery` generation as `tag`, verifiable, with its -// publication files carrying `marker` bytes. -function olderPublication(self, { - tag, marker = 'same', provenance = self.provenance, recovery = 0, releaseId = 4343, -}) { - const correlationId = runNames.computeCorrelationId(provenance, { recovery }); - const directory = path.join(self.tmp, `published-${correlationId}`); - writeBridgeCandidate(directory, { - releaseTag: tag, - releaseRebuild: 0, - correlationId, - runId: '777', - marker: Buffer.from(marker), - upstreamTag: provenance.upstreamTag, - upstreamCommit: provenance.upstreamCommit, - nativeReleaseTag: provenance.nativeReleaseTag, - nativeManifestSha256: provenance.nativeManifestSha256, - nativeCommit: provenance.nativeCommit, - }); - const [release, releaseRoutes, assetBlobs, attestations] = alignedRelease(directory, { - tag, provenance, correlationId, releaseId, - }); - // alignedRelease numbers every release's assets alike; keep each one's own. - const releaseBlobs = {}; - for (const asset of release.assets) { - const blob = assetBlobs[`repos/${ASSETS_REPOSITORY}/releases/assets/${asset.id}`]; - asset.id += (releaseId - 4343) * 100; - releaseBlobs[`repos/${ASSETS_REPOSITORY}/releases/assets/${asset.id}`] = blob; - } - return { - release, releaseRoutes, releaseBlobs, attestations, - }; -} - -test('older native at the published alignment is superseded by a newer scanned native', withAdvancePipelineFixture(async (self) => { - const older = self.provenance; - const newer = self.newerNative(); - const releases = [priorAlignment(self)]; - const gateway = scanGateway(self, { releases, provenances: [older, newer] }); - const [result, backlog] = await scanBacklog(self, gateway, [older, newer]); - assert.deepEqual(backlog.errors, []); - assert.equal(result, 0); - assert.deepEqual(rows(backlog), [ - ['v0.2.0', OrchestrationAction.SUPERSEDED, null], - ['v0.2.1', OrchestrationAction.DISPATCH_CANDIDATE, 'v0.1.41'], - ]); - assert.ok(backlog.plans[0].reason.includes('newer stable native release')); - assert.deepEqual(dispatched(backlog), [[model.CANDIDATE_WORKFLOW_FILE, 'v0.1.41']]); - assert.deepEqual(gateway.dispatches, []); -})); - -test('older native with a failed or in-flight candidate is observed, not superseded', withAdvancePipelineFixture((self) => { - const binding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.41', releaseRebuild: 0 }); - for (const [status, conclusion, action] of [ - ['completed', 'failure', OrchestrationAction.BLOCKED], - ['in_progress', null, OrchestrationAction.IN_FLIGHT], - ]) { - const run = runPayload({ - runId: OLDER_IDS.candidate, - path: model.CANDIDATE_WORKFLOW_PATH, - runName: runNames.candidateRunName(self.correlationId, binding), - status, - conclusion, - }); - const gateway = new FakeGateway({ jsonRoutes: self.routes({ releases: [priorAlignment(self)], candidateRuns: [run] }) }); - const plan = driver.advancePipeline(gateway, { - provenance: self.provenance, - workspace: self.tmp, - dryRun: true, - newerNativeScanned: true, - scannedProvenances: [self.provenance, self.newerNative()], - }); - assert.equal(plan.action, action, status); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.41', status); - } -})); - -test('fresh older native does not hold back a newer native pipeline with a lower tag', withAdvancePipelineFixture(async (self) => { - const older = self.provenance; - const newer = self.newerNative(); - const releases = [priorAlignment(self)]; - const newerPipeline = pipeline(self, { provenance: newer, releaseTag: 'v0.1.41', ids: NEWER_IDS }); - const gateway = scanGateway(self, { releases, provenances: [older, newer], pipelines: [newerPipeline] }); - const [result, backlog] = await scanBacklog(self, gateway, [older, newer]); - assert.deepEqual(backlog.errors, []); - assert.equal(result, 0); - assert.deepEqual(rows(backlog), [ - ['v0.2.0', OrchestrationAction.SUPERSEDED, null], - ['v0.2.1', OrchestrationAction.DISPATCH_PUBLISH, 'v0.1.41'], - ]); - assert.deepEqual(dispatched(backlog), [[model.PUBLISH_WORKFLOW_FILE, 'v0.1.41']]); -})); - -test('older pipeline above a newer native pipeline tag is superseded, not published first', withAdvancePipelineFixture(async (self) => { - const older = self.provenance; - const newer = self.newerNative(); - const releases = [priorAlignment(self)]; - for (const qualified of [true, false]) { - const pipelines = [ - pipeline(self, { - provenance: older, releaseTag: 'v0.1.42', ids: OLDER_IDS, qualified, - }), - pipeline(self, { provenance: newer, releaseTag: 'v0.1.41', ids: NEWER_IDS }), - ]; - const gateway = scanGateway(self, { releases, provenances: [older, newer], pipelines }); - const [result, backlog] = await scanBacklog(self, gateway, [older, newer]); - assert.deepEqual(backlog.errors, [], `qualified=${qualified}`); - assert.equal(result, 0, `qualified=${qualified}`); - assert.deepEqual(rows(backlog), [ - ['v0.2.0', OrchestrationAction.SUPERSEDED, 'v0.1.42'], - ['v0.2.1', OrchestrationAction.DISPATCH_PUBLISH, 'v0.1.41'], - ], `qualified=${qualified}`); - assert.ok(backlog.plans[0].reason.includes('ordering rollback')); - assert.deepEqual(dispatched(backlog), [[model.PUBLISH_WORKFLOW_FILE, 'v0.1.41']], `qualified=${qualified}`); - } -})); - -test('stranded pipeline overtaken by an identical publication is satisfied, not retried', withAdvancePipelineFixture(async (self) => { - const older = self.provenance; - const newer = self.newerNative(); - const published = olderPublication(self, { tag: 'v0.1.42' }); - const releases = [priorAlignment(self), published.release]; - const gateway = scanGateway(self, { - releases, - provenances: [older, newer], - pipelines: [pipeline(self, { - provenance: newer, releaseTag: 'v0.1.41', ids: NEWER_IDS, publishFailed: true, - })], - ...published, - }); - const [result, backlog] = await scanBacklog(self, gateway, [older, newer]); - assert.deepEqual(backlog.errors, []); - assert.equal(result, 0); - assert.deepEqual(rows(backlog), [ - ['v0.2.0', OrchestrationAction.NOOP, 'v0.1.42'], - ['v0.2.1', OrchestrationAction.SATISFIED_BY_IDENTICAL_RELEASE, 'v0.1.42'], - ]); - const [, satisfied] = backlog.plans; - assert.equal(satisfied.candidate_run_id, NEWER_IDS.candidate); - assert.equal(satisfied.qualification_run_id, NEWER_IDS.qualification); - assert.ok(satisfied.reason.includes('ordering rollback')); - assert.ok(satisfied.reason.includes('records native v0.2.0')); - assert.deepEqual(dispatched(backlog), []); -})); - -// Run identities of the newer native's recovery pipelines, by generation, -// production-sized so their run names meet the real length limit. -const recoveryIds = (generation) => Object.freeze({ - candidate: `4066806${generation}531`, - candidateArtifact: 70 + generation * 2, - qualification: `4067066${generation}532`, - qualificationArtifact: 71 + generation * 2, - publish: `4067425${generation}533`, -}); - -// The issue #158 end state: the newer native's qualified v0.1.41 failed to -// publish as an ordering rollback after a refresh of the older native took -// v0.1.42 and published first with other bytes. -function strandedScan(self, { pipelines = [], releases = [], qualified = true } = {}) { - const older = self.provenance; - const newer = self.newerNative(); - const published = olderPublication(self, { tag: 'v0.1.42', marker: 'older-bytes' }); - const extra = releases.map((release, index) => olderPublication(self, { ...release, releaseId: 4400 + index })); - const gateway = scanGateway(self, { - releases: [priorAlignment(self), published.release, ...extra.map((item) => item.release)], - provenances: [older, newer], - pipelines: [ - pipeline(self, { - provenance: newer, releaseTag: 'v0.1.41', ids: NEWER_IDS, qualified, publishFailed: qualified, - }), - ...pipelines, - ], - releaseRoutes: Object.assign({}, published.releaseRoutes, ...extra.map((item) => item.releaseRoutes)), - releaseBlobs: Object.assign({}, published.releaseBlobs, ...extra.map((item) => item.releaseBlobs)), - attestations: [...published.attestations, ...extra.flatMap((item) => item.attestations)], - }); - return scanBacklog(self, gateway, [older, newer]); -} - -test('stranded pipeline overtaken by a differing publication is recovered under a fresh output tag', withAdvancePipelineFixture(async (self) => { - const newer = self.newerNative(); - const stranded = runNames.computeCorrelationId(newer); - const recovery = runNames.computeCorrelationId(newer, { recovery: 1 }); - const ids = recoveryIds(1); - const newerPlan = (backlog) => backlog.plans[1]; - - const [result, first] = await strandedScan(self); - assert.deepEqual(first.errors, []); - assert.equal(result, 0); - assert.deepEqual(rows(first), [ - ['v0.2.0', OrchestrationAction.NOOP, 'v0.1.42'], - ['v0.2.1', OrchestrationAction.DISPATCH_CANDIDATE, 'v0.1.43'], - ]); - assert.equal(newerPlan(first).correlation_id, recovery); - assert.equal(newerPlan(first).dispatch_inputs.orchestrator_correlation_id, recovery); - assert.equal(newerPlan(first).dispatch_inputs.native_release_tag, 'v0.2.1'); - assert.equal(newerPlan(first).candidate_run_id, null); - assert.ok(newerPlan(first).reason.includes(`candidate ${NEWER_IDS.candidate}`)); - assert.ok(newerPlan(first).reason.includes('differ from v0.1.42')); - assert.ok(newerPlan(first).reason.includes(`recovery pipeline ${recovery}`)); - assert.deepEqual(dispatched(first), [[model.CANDIDATE_WORKFLOW_FILE, 'v0.1.43']]); - - // Every later stage is a new first attempt of the recovery pipeline. - const [, second] = await strandedScan(self, { - pipelines: [pipeline(self, { - provenance: newer, releaseTag: 'v0.1.43', ids, qualified: false, recovery: 1, - })], - }); - assert.deepEqual(rows(second)[1], ['v0.2.1', OrchestrationAction.DISPATCH_QUALIFICATION, 'v0.1.43']); - assert.deepEqual(newerPlan(second).dispatch_inputs, { - candidate_run_id: ids.candidate, orchestrator_correlation_id: recovery, - }); - const [, third] = await strandedScan(self, { - pipelines: [pipeline(self, { - provenance: newer, releaseTag: 'v0.1.43', ids, recovery: 1, - })], - }); - assert.deepEqual(rows(third)[1], ['v0.2.1', OrchestrationAction.DISPATCH_PUBLISH, 'v0.1.43']); - assert.equal(newerPlan(third).dispatch_inputs.candidate_run_id, ids.candidate); - assert.equal(newerPlan(third).dispatch_inputs.qualification_run_id, ids.qualification); - assert.equal(newerPlan(third).dispatch_inputs.orchestrator_correlation_id, recovery); - - // Once the recovery pipeline publishes, whatever its bytes, the stranded - // one is settled by it. - for (const marker of ['same', 'rebuilt-bytes']) { - const [settledResult, settled] = await strandedScan(self, { - pipelines: [pipeline(self, { - provenance: newer, releaseTag: 'v0.1.43', ids, recovery: 1, - })], - releases: [{ - tag: 'v0.1.43', provenance: newer, recovery: 1, marker, - }], - }); - assert.deepEqual(settled.errors, [], marker); - assert.equal(settledResult, 0, marker); - assert.deepEqual(rows(settled)[1], ['v0.2.1', OrchestrationAction.NOOP, 'v0.1.43'], marker); - assert.equal(newerPlan(settled).correlation_id, recovery, marker); - assert.ok(newerPlan(settled).reason.includes(`candidate ${NEWER_IDS.candidate}`), marker); - assert.deepEqual(dispatched(settled), [], marker); - } - assert.notEqual(stranded, recovery); -})); - -test('a stale listing that omits a live recovery candidate re-plans instead of dispatching another', withAdvancePipelineFixture((self) => { - const older = self.provenance; - const newer = self.newerNative(); - const recovery = runNames.computeCorrelationId(newer, { recovery: 1 }); - const published = olderPublication(self, { tag: 'v0.1.42', marker: 'older-bytes' }); - const stranded = pipeline(self, { - provenance: newer, releaseTag: 'v0.1.41', ids: NEWER_IDS, publishFailed: true, - }); - const gateway = scanGateway(self, { - releases: [priorAlignment(self), published.release], - provenances: [newer], - pipelines: [stranded], - ...published, - }); - // Only the guard's unfiltered newest page shows the recovery candidate. - const live = runPayload({ - runId: recoveryIds(1).candidate, - path: model.CANDIDATE_WORKFLOW_PATH, - runName: runNames.candidateRunName(recovery, new model.PipelineBinding({ - bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.43', releaseRebuild: 0, - })), - status: 'in_progress', - }); - gateway.jsonRoutes[workflowRuns.recentWorkflowRunsPath(model.CANDIDATE_WORKFLOW_FILE)] = runsResponse([ - live, ...stranded.runs.candidate, - ]); - const plan = driver.advancePipeline(gateway, { - provenance: newer, workspace: self.tmp, dryRun: true, scannedProvenances: [older, newer], - }); - assert.equal(plan.action, OrchestrationAction.IN_FLIGHT); - assert.equal(plan.correlationId, recovery); - assert.equal(plan.dispatchWorkflow, null); - assert.ok(plan.reason.includes('re-planned once')); -})); - -test('an unqualified overtaken candidate is recovered instead of qualified', withAdvancePipelineFixture(async (self) => { - const [result, backlog] = await strandedScan(self, { qualified: false }); - assert.equal(result, 0); - assert.deepEqual(rows(backlog)[1], ['v0.2.1', OrchestrationAction.DISPATCH_CANDIDATE, 'v0.1.43']); - assert.equal(backlog.plans[1].correlation_id, runNames.computeCorrelationId(self.newerNative(), { recovery: 1 })); -})); - -test('an overtaken recovery pipeline moves to the next generation until the last one blocks', withAdvancePipelineFixture(async (self) => { - const newer = self.newerNative(); - // A recovery pipeline of the older native published later still. - const overtaking = { tag: 'v0.1.50', marker: 'later-older-bytes', recovery: 1 }; - const generations = (count) => Array.from({ length: count }, (_, index) => pipeline(self, { - provenance: newer, releaseTag: `v0.1.4${index + 3}`, ids: recoveryIds(index + 1), recovery: index + 1, publishFailed: true, - })); - for (let count = 1; count < driver.MAX_RECOVERY_GENERATIONS; count += 1) { - const [result, backlog] = await strandedScan(self, { pipelines: generations(count), releases: [overtaking] }); - assert.equal(result, 0, `count=${count}`); - assert.deepEqual(rows(backlog)[1], ['v0.2.1', OrchestrationAction.DISPATCH_CANDIDATE, 'v0.1.51'], `count=${count}`); - assert.equal(backlog.plans[1].correlation_id, runNames.computeCorrelationId(newer, { recovery: count + 1 })); - } - const [result, backlog] = await strandedScan(self, { - pipelines: generations(driver.MAX_RECOVERY_GENERATIONS), releases: [overtaking], - }); - assert.equal(result, 1); - const last = driver.MAX_RECOVERY_GENERATIONS; - assert.deepEqual(rows(backlog)[1], ['v0.2.1', OrchestrationAction.BLOCKED, `v0.1.4${last + 2}`]); - assert.equal(backlog.plans[1].correlation_id, runNames.computeCorrelationId(newer, { recovery: last })); - assert.ok(backlog.plans[1].reason.includes(`recovery generation ${last} is the last automatic one`)); - assert.deepEqual(dispatched(backlog), []); -})); - -test('a recovery publication of another scanned native is compared like its stranded pipeline', withAdvancePipelineFixture((self) => { - const older = self.provenance; - const newer = self.newerNative(); - const published = olderPublication(self, { tag: 'v0.1.42', recovery: 1 }); - const gateway = scanGateway(self, { - releases: [published.release], - provenances: [newer], - pipelines: [pipeline(self, { - provenance: newer, releaseTag: 'v0.1.41', ids: NEWER_IDS, publishFailed: true, - })], - ...published, - }); - const plan = driver.advancePipeline(gateway, { - provenance: newer, workspace: self.tmp, dryRun: true, scannedProvenances: [older, newer], - }); - assert.equal(plan.action, OrchestrationAction.SATISFIED_BY_IDENTICAL_RELEASE); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.42'); -})); - -test('a recovery pipeline of a scanned newer native proves its own claim', withAdvancePipelineFixture((self) => { - const older = self.provenance; - const newer = self.newerNative(); - const gateway = scanGateway(self, { - releases: [priorAlignment(self)], - provenances: [older, newer], - pipelines: [ - pipeline(self, { provenance: older, releaseTag: 'v0.1.44', ids: OLDER_IDS }), - pipeline(self, { - provenance: newer, releaseTag: 'v0.1.43', ids: recoveryIds(1), qualified: false, recovery: 1, - }), - ], - }); - const plan = driver.advancePipeline(gateway, { - provenance: older, workspace: self.tmp, dryRun: true, newerNativeScanned: true, scannedProvenances: [older, newer], - }); - assert.equal(plan.action, OrchestrationAction.SUPERSEDED); - assert.ok(plan.reason.includes('v0.1.43 claimed by the pipeline of newer native release v0.2.1')); - assert.equal(gateway.apiPaths.includes(`repos/${NATIVE_REPOSITORY}/releases?per_page=100`), false); -})); - -test('overtaken pipeline is blocked when the overtaking release is not of a scanned native', withAdvancePipelineFixture((self) => { - const newer = self.newerNative(); - const published = olderPublication(self, { tag: 'v0.1.42' }); - const gateway = scanGateway(self, { - releases: [published.release], - provenances: [newer], - pipelines: [pipeline(self, { - provenance: newer, releaseTag: 'v0.1.41', ids: NEWER_IDS, publishFailed: true, - })], - ...published, - }); - const plan = driver.advancePipeline(gateway, { - provenance: newer, workspace: self.tmp, dryRun: true, scannedProvenances: [newer], - }); - assert.equal(plan.action, OrchestrationAction.BLOCKED); - assert.ok(plan.reason.includes('cannot be compared')); - assert.equal(plan.dispatchWorkflow, null); - assert.equal(gateway.apiPaths.includes(`repos/${ASSETS_REPOSITORY}/releases/${published.release.id}`), false); -})); - -test('overtaken candidate is settled before a qualification is dispatched', withAdvancePipelineFixture((self) => { - const older = self.provenance; - const newer = self.newerNative(); - const published = olderPublication(self, { tag: 'v0.1.42' }); - const gateway = scanGateway(self, { - releases: [published.release], - provenances: [newer], - pipelines: [pipeline(self, { - provenance: newer, releaseTag: 'v0.1.41', ids: NEWER_IDS, qualified: false, - })], - ...published, - }); - const plan = driver.advancePipeline(gateway, { - provenance: newer, workspace: self.tmp, dryRun: true, scannedProvenances: [older, newer], - }); - assert.equal(plan.action, OrchestrationAction.SATISFIED_BY_IDENTICAL_RELEASE); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.42'); - assert.equal(plan.qualificationRunId, null); - assert.equal(plan.dispatchWorkflow, null); -})); - -test('overtaken pipeline behind a publication barrier is settled, not left waiting', withAdvancePipelineFixture((self) => { - const older = self.provenance; - const newer = self.newerNative(); - const published = olderPublication(self, { tag: 'v0.1.42' }); - const gateway = scanGateway(self, { - releases: [published.release], - provenances: [newer], - pipelines: [pipeline(self, { provenance: newer, releaseTag: 'v0.1.41', ids: NEWER_IDS })], - ...published, - }); - const plan = driver.advancePipeline(gateway, { - provenance: newer, - workspace: self.tmp, - dryRun: true, - publicationAllowed: false, - publicationBarrierNativeTag: 'v0.2.0-1', - scannedProvenances: [older, newer], - }); - assert.equal(plan.action, OrchestrationAction.SATISFIED_BY_IDENTICAL_RELEASE); - assert.equal(plan.qualificationRunId, NEWER_IDS.qualification); -})); - -test('older pipeline below a newer native pipeline tag still publishes first', withAdvancePipelineFixture(async (self) => { - const older = self.provenance; - const newer = self.newerNative(); - const pipelines = [ - pipeline(self, { provenance: older, releaseTag: 'v0.1.41', ids: OLDER_IDS }), - pipeline(self, { provenance: newer, releaseTag: 'v0.1.42', ids: NEWER_IDS }), - ]; - const draft = { ...assetReleaseStub('v0.1.50'), draft: true }; - const gateway = scanGateway(self, { releases: [priorAlignment(self), draft], provenances: [older, newer], pipelines }); - const [result, backlog] = await scanBacklog(self, gateway, [older, newer]); - assert.deepEqual(backlog.errors, []); - assert.equal(result, 0); - assert.deepEqual(rows(backlog), [ - ['v0.2.0', OrchestrationAction.DISPATCH_PUBLISH, 'v0.1.41'], - ['v0.2.1', OrchestrationAction.WAITING_FOR_PRIOR_PUBLICATION, 'v0.1.42'], - ]); - assert.deepEqual(dispatched(backlog), [[model.PUBLISH_WORKFLOW_FILE, 'v0.1.41']]); -})); - -test('overtaken older pipeline with differing bytes does not hold back a newer native', withAdvancePipelineFixture(async (self) => { - const older = self.provenance; - const middle = self.newerNative(); - const newest = self.newerNative({ nativeReleaseTag: 'v0.2.1-1', nativeReleasePublishedAt: '2026-08-22T12:34:56Z' }); - const published = olderPublication(self, { tag: 'v0.1.42', marker: 'older-bytes' }); - const gateway = scanGateway(self, { - releases: [priorAlignment(self), published.release], - provenances: [older, middle, newest], - pipelines: [ - pipeline(self, { - provenance: middle, releaseTag: 'v0.1.41', ids: NEWER_IDS, publishFailed: true, - }), - pipeline(self, { provenance: newest, releaseTag: 'v0.1.43', ids: OLDER_IDS }), - ], - ...published, - }); - const [result, backlog] = await scanBacklog(self, gateway, [older, middle, newest]); - assert.deepEqual(backlog.errors, []); - assert.equal(result, 0); - assert.deepEqual(rows(backlog), [ - ['v0.2.0', OrchestrationAction.NOOP, 'v0.1.42'], - ['v0.2.1', OrchestrationAction.SUPERSEDED, 'v0.1.41'], - ['v0.2.1-1', OrchestrationAction.DISPATCH_PUBLISH, 'v0.1.43'], - ]); - assert.deepEqual(dispatched(backlog), [[model.PUBLISH_WORKFLOW_FILE, 'v0.1.43']]); -})); - -test('overtaken candidate is satisfied by an identical release of its own native', withAdvancePipelineFixture((self) => { - const newer = self.newerNative(); - const priorBuild = replaceProvenance(newer, { bridgeSourceSha: ADVANCED_BRIDGE_SHA, bridgeBuildSha: ADVANCED_BRIDGE_SHA }); - const correlationId = runNames.computeCorrelationId(priorBuild); - const directory = path.join(self.tmp, 'published-prior-build'); - writeBridgeCandidate(directory, { - releaseTag: 'v0.1.42', - releaseRebuild: 0, - correlationId, - bridgeCommit: ADVANCED_BRIDGE_SHA, - runId: '777', - marker: Buffer.from('same'), - upstreamTag: newer.upstreamTag, - upstreamCommit: newer.upstreamCommit, - nativeReleaseTag: newer.nativeReleaseTag, - nativeManifestSha256: newer.nativeManifestSha256, - nativeCommit: newer.nativeCommit, - }); - const [release, releaseRoutes, releaseBlobs, attestations] = alignedRelease(directory, { - tag: 'v0.1.42', provenance: priorBuild, correlationId, - }); - const gateway = scanGateway(self, { - releases: [release], - provenances: [newer], - pipelines: [pipeline(self, { - provenance: newer, releaseTag: 'v0.1.41', ids: NEWER_IDS, publishFailed: true, - })], - releaseRoutes, - releaseBlobs, - attestations, - }); - gateway.jsonRoutes[`repos/${BRIDGE_REPOSITORY}/compare/${ADVANCED_BRIDGE_SHA}...main`] = { status: 'ahead' }; - const plan = driver.advancePipeline(gateway, { - provenance: newer, workspace: self.tmp, dryRun: true, scannedProvenances: [newer], - }); - assert.equal(plan.action, OrchestrationAction.SATISFIED_BY_IDENTICAL_RELEASE); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.42'); -})); - -test('targeted newer-native scan then wider scans keep output tags in native order', withAdvancePipelineFixture(async (self) => { - const older = self.provenance; - const newer = self.newerNative(); - const releases = [priorAlignment(self)]; - const [, targeted] = await scanBacklog(self, scanGateway(self, { releases, provenances: [newer] }), [newer]); - assert.deepEqual(rows(targeted), [['v0.2.1', OrchestrationAction.DISPATCH_CANDIDATE, 'v0.1.41']]); - - const candidate = pipeline(self, { - provenance: newer, releaseTag: 'v0.1.41', ids: NEWER_IDS, qualified: false, - }); - const [, woken] = await scanBacklog(self, scanGateway(self, { - releases, provenances: [older, newer], pipelines: [candidate], - }), [older, newer]); - assert.deepEqual(rows(woken), [ - ['v0.2.0', OrchestrationAction.SUPERSEDED, null], - ['v0.2.1', OrchestrationAction.DISPATCH_QUALIFICATION, 'v0.1.41'], - ]); - - // A prior-native refresh that already holds a higher tag arrives later. - const [result, later] = await scanBacklog(self, scanGateway(self, { - releases, - provenances: [older, newer], - pipelines: [ - pipeline(self, { provenance: older, releaseTag: 'v0.1.42', ids: OLDER_IDS }), - pipeline(self, { provenance: newer, releaseTag: 'v0.1.41', ids: NEWER_IDS }), - ], - }), [older, newer]); - assert.equal(result, 0); - assert.deepEqual(rows(later), [ - ['v0.2.0', OrchestrationAction.SUPERSEDED, 'v0.1.42'], - ['v0.2.1', OrchestrationAction.DISPATCH_PUBLISH, 'v0.1.41'], - ]); -})); - -test('targeted older-native scan does not start a pipeline above a newer native pipeline', withAdvancePipelineFixture(async (self) => { - const older = self.provenance; - const newer = self.newerNative(); - for (const qualified of [true, false]) { - const gateway = scanGateway(self, { - releases: [priorAlignment(self)], - provenances: [older], - pipelines: [pipeline(self, { - provenance: newer, releaseTag: 'v0.1.41', ids: NEWER_IDS, qualified, - })], - nativeReleases: [nativeRelease(newer)], - }); - const [result, backlog] = await scanBacklog(self, gateway, [older]); - assert.deepEqual(backlog.errors, [], `qualified=${qualified}`); - assert.equal(result, 0, `qualified=${qualified}`); - assert.deepEqual(rows(backlog), [['v0.2.0', OrchestrationAction.SUPERSEDED, null]], `qualified=${qualified}`); - assert.ok(backlog.plans[0].reason.includes('v0.2.1'), `qualified=${qualified}`); - assert.deepEqual(dispatched(backlog), [], `qualified=${qualified}`); - } -})); - -test('targeted older-native scan supersedes its pipeline above a newer native pipeline', withAdvancePipelineFixture(async (self) => { - const older = self.provenance; - const newer = self.newerNative(); - for (const qualified of [true, false]) { - const gateway = scanGateway(self, { - releases: [priorAlignment(self)], - provenances: [older], - pipelines: [ - pipeline(self, { - provenance: older, releaseTag: 'v0.1.42', ids: OLDER_IDS, qualified, - }), - pipeline(self, { - provenance: newer, releaseTag: 'v0.1.41', ids: NEWER_IDS, qualified: false, - }), - ], - nativeReleases: [nativeRelease(newer)], - }); - const [result, backlog] = await scanBacklog(self, gateway, [older]); - assert.deepEqual(backlog.errors, [], `qualified=${qualified}`); - assert.equal(result, 0, `qualified=${qualified}`); - assert.deepEqual(rows(backlog), [['v0.2.0', OrchestrationAction.SUPERSEDED, 'v0.1.42']], `qualified=${qualified}`); - assert.ok(backlog.plans[0].reason.includes('ordering rollback'), `qualified=${qualified}`); - assert.deepEqual(dispatched(backlog), [], `qualified=${qualified}`); - } -})); - -test('targeted older-native scan still publishes a pipeline below a newer native pipeline', withAdvancePipelineFixture(async (self) => { - const older = self.provenance; - const newer = self.newerNative(); - const gateway = scanGateway(self, { - releases: [priorAlignment(self)], - provenances: [older], - pipelines: [ - pipeline(self, { provenance: older, releaseTag: 'v0.1.41', ids: OLDER_IDS }), - pipeline(self, { - provenance: newer, releaseTag: 'v0.1.42', ids: NEWER_IDS, qualified: false, - }), - ], - }); - const [result, backlog] = await scanBacklog(self, gateway, [older]); - assert.equal(result, 0); - assert.deepEqual(rows(backlog), [['v0.2.0', OrchestrationAction.DISPATCH_PUBLISH, 'v0.1.41']]); -})); - -test('a released newer-native claim of another build does not supersede an older native', withAdvancePipelineFixture(async (self) => { - const older = self.provenance; - const newer = replaceProvenance(self.newerNative(), { bridgeBuildSha: ADVANCED_BRIDGE_SHA }); - const binding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.41', releaseRebuild: 0 }); - const failed = runPayload({ - runId: NEWER_IDS.candidate, - path: model.CANDIDATE_WORKFLOW_PATH, - runName: runNames.candidateRunName(runNames.computeCorrelationId(newer), binding), - conclusion: 'failure', - }); - const gateway = scanGateway(self, { - releases: [priorAlignment(self)], - provenances: [older], - pipelines: [{ routes: {}, blobs: {}, runs: { candidate: [failed], qualification: [], publish: [] } }], - }); - const [result, backlog] = await scanBacklog(self, gateway, [older]); - assert.equal(result, 0); - assert.deepEqual(rows(backlog), [['v0.2.0', OrchestrationAction.DISPATCH_CANDIDATE, 'v0.1.41']]); -})); - -// A failed or successful candidate run of `provenance`'s pipeline bound to -// `releaseTag`, with no later stage. -function claimOnly(provenance, { releaseTag = 'v0.1.41', conclusion = 'failure', correlationId = null } = {}) { - const binding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag, releaseRebuild: 0 }); - const run = runPayload({ - runId: NEWER_IDS.candidate, - path: model.CANDIDATE_WORKFLOW_PATH, - runName: runNames.candidateRunName(correlationId ?? runNames.computeCorrelationId(provenance), binding), - conclusion, - }); - return { routes: {}, blobs: {}, runs: { candidate: [run], qualification: [], publish: [] } }; -} - -// A targeted scan of the older native only must fail visibly, naming the claim. -async function assertUnprovenClaimBlocks(self, gateway, { releaseTag, claimed, native }) { - const [result, backlog] = await scanBacklog(self, gateway, [self.provenance]); - assert.deepEqual(backlog.errors, []); - assert.equal(result, 1); - assert.deepEqual(rows(backlog), [['v0.2.0', OrchestrationAction.BLOCKED, releaseTag]]); - const { reason } = backlog.plans[0]; - assert.ok(reason.includes(native) && reason.includes(claimed), reason); - assert.deepEqual(dispatched(backlog), []); -} - -test('a claim of a newer native that is no longer published blocks a fresh older native', withAdvancePipelineFixture(async (self) => { - const newer = self.newerNative(); - for (const [label, nativeReleases] of [ - ['deleted', []], - ['draft', [nativeRelease(newer, { draft: true })]], - ['replaced assets', [nativeRelease(newer, { manifestSha256: 'e'.repeat(64) })]], - ]) { - for (const conclusion of ['failure', 'success']) { - const gateway = scanGateway(self, { - releases: [priorAlignment(self)], - provenances: [self.provenance], - pipelines: [claimOnly(newer, { conclusion })], - nativeReleases, - }); - await assertUnprovenClaimBlocks(self, gateway, { releaseTag: null, claimed: 'v0.1.41', native: 'v0.2.1' }); - assert.ok(gateway.dispatches.length === 0, `${label} ${conclusion}`); - } - } -})); - -test('a claim of a newer native that is no longer published blocks an older qualified pipeline', withAdvancePipelineFixture(async (self) => { - const gateway = scanGateway(self, { - releases: [priorAlignment(self)], - provenances: [self.provenance], - pipelines: [ - pipeline(self, { provenance: self.provenance, releaseTag: 'v0.1.42', ids: OLDER_IDS }), - claimOnly(self.newerNative()), - ], - }); - await assertUnprovenClaimBlocks(self, gateway, { releaseTag: 'v0.1.42', claimed: 'v0.1.41', native: 'v0.2.1' }); -})); - -test('a crafted claim of a nonexistent newer native blocks instead of superseding', withAdvancePipelineFixture(async (self) => { - const crafted = `auto-stable-v99.0.0-${'0'.repeat(16)}-build-${BRIDGE_SHA.slice(0, 16)}`; - const gateway = scanGateway(self, { - releases: [priorAlignment(self)], - provenances: [self.provenance], - pipelines: [claimOnly(self.provenance, { correlationId: crafted })], - nativeReleases: [nativeRelease(self.newerNative())], - }); - await assertUnprovenClaimBlocks(self, gateway, { releaseTag: null, claimed: 'v0.1.41', native: 'v99.0.0' }); -})); diff --git a/tests/release/orchestrator/driver_publication_test.mjs b/tests/release/orchestrator/driver_publication_test.mjs deleted file mode 100644 index e47edae..0000000 --- a/tests/release/orchestrator/driver_publication_test.mjs +++ /dev/null @@ -1,171 +0,0 @@ -// Tests of the qualification and publication stages of advancePipeline, one -// test per test method of scripts/release_orchestrator_driver_publication_test.py, -// with the same names and assertions. - -import assert from 'node:assert/strict'; -import path from 'node:path'; -import { test } from 'node:test'; - -import { BRIDGE_REPOSITORY, ContractError } from '../../../scripts/release/contract.mjs'; -import * as driver from '../../../scripts/release/orchestrator/driver.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as runNames from '../../../scripts/release/orchestrator/run_names.mjs'; -import { ATTESTATION_ARTIFACT_NAME, CANDIDATE_ARTIFACT_NAME } from '../../../scripts/release/qualification.mjs'; -import { - BRIDGE_SHA, CANDIDATE_ARTIFACT_ID, CANDIDATE_RUN_ID, DEFAULT_BRANCH, FakeGateway, HEAD_SHA, QUALIFICATION_ARTIFACT_ID, - QUALIFICATION_RUN_ID, artifactInventory, directoryMembers, flatZip, runPayload, runsResponse, withAdvancePipelineFixture, - writeBridgeCandidate, -} from './fixtures.mjs'; -import { attestationBytes, onDispatch, runsKey } from './driver_fixtures.mjs'; - -const RUNS = `repos/${BRIDGE_REPOSITORY}/actions/runs`; -const ARTIFACTS = `repos/${BRIDGE_REPOSITORY}/actions/artifacts`; - -test('test_candidate_success_dispatches_hosted_qualification', withAdvancePipelineFixture((self) => { - const candidateDir = path.join(self.tmp, 'candidate-src'); - writeBridgeCandidate(candidateDir, { - releaseTag: 'v0.1.40', releaseRebuild: 0, correlationId: self.correlationId, runId: CANDIDATE_RUN_ID, - }); - const members = directoryMembers(candidateDir); - const succeeded = runPayload({ runId: CANDIDATE_RUN_ID, path: model.CANDIDATE_WORKFLOW_PATH, runName: self.candidateName }); - const routes = self.routes({ candidateRuns: [succeeded] }); - routes[`${RUNS}/${CANDIDATE_RUN_ID}`] = succeeded; - routes[`${RUNS}/${CANDIDATE_RUN_ID}/artifacts?per_page=100`] = artifactInventory({ - runId: CANDIDATE_RUN_ID, name: CANDIDATE_ARTIFACT_NAME, artifactId: CANDIDATE_ARTIFACT_ID, - }); - routes[`repos/${BRIDGE_REPOSITORY}/compare/${HEAD_SHA}...${DEFAULT_BRANCH}`] = { status: 'identical' }; - routes[`repos/${BRIDGE_REPOSITORY}/compare/${BRIDGE_SHA}...${DEFAULT_BRANCH}`] = { status: 'ahead' }; - const gateway = new FakeGateway({ - jsonRoutes: routes, - blobRoutes: { [`${ARTIFACTS}/${CANDIDATE_ARTIFACT_ID}/zip`]: flatZip(members) }, - }); - const dispatched = runPayload({ - runId: '4201', - path: model.QUALIFICATION_WORKFLOW_PATH, - runName: runNames.qualificationRunName(self.correlationId, CANDIDATE_RUN_ID), - status: 'in_progress', - conclusion: null, - }); - const readbackKey = runsKey(model.QUALIFICATION_WORKFLOW_FILE); - onDispatch(gateway, () => { - gateway.jsonRoutes[readbackKey] = runsResponse([dispatched]); - }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.DISPATCH_QUALIFICATION); - assert.equal(plan.candidateRunId, CANDIDATE_RUN_ID); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.40'); - assert.equal(gateway.dispatches.length, 1); - const record = gateway.dispatches[0]; - assert.equal(record.workflowFile, model.QUALIFICATION_WORKFLOW_FILE); - assert.equal(record.ref, DEFAULT_BRANCH); - assert.deepEqual(record.inputs, { - orchestrator_correlation_id: self.correlationId, - candidate_run_id: CANDIDATE_RUN_ID, - }); -})); - -test('test_failed_qualification_blocks_instead_of_retrying_forever', withAdvancePipelineFixture((self) => { - const candidateDir = path.join(self.tmp, 'candidate-failed-qualification'); - writeBridgeCandidate(candidateDir, { - releaseTag: 'v0.1.40', releaseRebuild: 0, correlationId: self.correlationId, runId: CANDIDATE_RUN_ID, - }); - const succeeded = runPayload({ runId: CANDIDATE_RUN_ID, path: model.CANDIDATE_WORKFLOW_PATH, runName: self.candidateName }); - const failedQualification = runPayload({ - runId: '4201', - path: model.QUALIFICATION_WORKFLOW_PATH, - runName: runNames.qualificationRunName(self.correlationId, CANDIDATE_RUN_ID), - status: 'completed', - conclusion: 'failure', - }); - const routes = self.routes({ candidateRuns: [succeeded], qualificationRuns: [failedQualification] }); - routes[`${RUNS}/${CANDIDATE_RUN_ID}`] = succeeded; - routes[`${RUNS}/${CANDIDATE_RUN_ID}/artifacts?per_page=100`] = artifactInventory({ - runId: CANDIDATE_RUN_ID, name: CANDIDATE_ARTIFACT_NAME, artifactId: CANDIDATE_ARTIFACT_ID, - }); - routes[`repos/${BRIDGE_REPOSITORY}/compare/${HEAD_SHA}...${DEFAULT_BRANCH}`] = { status: 'identical' }; - const gateway = new FakeGateway({ - jsonRoutes: routes, - blobRoutes: { [`${ARTIFACTS}/${CANDIDATE_ARTIFACT_ID}/zip`]: flatZip(directoryMembers(candidateDir)) }, - }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.BLOCKED); - assert.deepEqual(gateway.dispatches, []); - assert.ok(plan.reason.includes('automatic qualification retries are disabled')); -})); - -// The routes and blobs of a proven candidate and its qualification, the -// attestation built from `attestationDir`'s candidate. -function qualifiedGateway(self, candidateDir, attestationDir) { - const members = directoryMembers(candidateDir); - const bytes = attestationBytes(attestationDir); - const candidateRun = runPayload({ runId: CANDIDATE_RUN_ID, path: model.CANDIDATE_WORKFLOW_PATH, runName: self.candidateName }); - const qualificationRun = runPayload({ - runId: QUALIFICATION_RUN_ID, - path: model.QUALIFICATION_WORKFLOW_PATH, - runName: runNames.qualificationRunName(self.correlationId, CANDIDATE_RUN_ID), - }); - const routes = self.routes({ candidateRuns: [candidateRun], qualificationRuns: [qualificationRun] }); - routes[`${RUNS}/${CANDIDATE_RUN_ID}`] = candidateRun; - routes[`${RUNS}/${QUALIFICATION_RUN_ID}`] = qualificationRun; - routes[`${RUNS}/${CANDIDATE_RUN_ID}/artifacts?per_page=100`] = artifactInventory({ - runId: CANDIDATE_RUN_ID, name: CANDIDATE_ARTIFACT_NAME, artifactId: CANDIDATE_ARTIFACT_ID, - }); - routes[`${RUNS}/${QUALIFICATION_RUN_ID}/artifacts?per_page=100`] = artifactInventory({ - runId: QUALIFICATION_RUN_ID, name: ATTESTATION_ARTIFACT_NAME, artifactId: QUALIFICATION_ARTIFACT_ID, - }); - routes[`repos/${BRIDGE_REPOSITORY}/compare/${HEAD_SHA}...${DEFAULT_BRANCH}`] = { status: 'identical' }; - return new FakeGateway({ - jsonRoutes: routes, - blobRoutes: { - [`${ARTIFACTS}/${CANDIDATE_ARTIFACT_ID}/zip`]: flatZip(members), - [`${ARTIFACTS}/${QUALIFICATION_ARTIFACT_ID}/zip`]: flatZip({ 'qualification-attestation.json': bytes }), - }, - }); -} - -test('test_full_pipeline_dispatches_publish_after_exact_attestation', withAdvancePipelineFixture((self) => { - const candidateDir = path.join(self.tmp, 'candidate-src'); - writeBridgeCandidate(candidateDir, { - releaseTag: 'v0.1.40', releaseRebuild: 0, correlationId: self.correlationId, runId: CANDIDATE_RUN_ID, - }); - const gateway = qualifiedGateway(self, candidateDir, candidateDir); - const publishName = runNames.publishRunName(self.correlationId, CANDIDATE_RUN_ID, QUALIFICATION_RUN_ID, self.binding); - const publishRun = runPayload({ - runId: '701', path: model.PUBLISH_WORKFLOW_PATH, runName: publishName, status: 'in_progress', conclusion: null, - }); - const publishKey = runsKey(model.PUBLISH_WORKFLOW_FILE); - onDispatch(gateway, () => { - gateway.jsonRoutes[publishKey] = runsResponse([publishRun]); - }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.DISPATCH_PUBLISH); - assert.equal(gateway.dispatches.length, 1); - const { inputs } = gateway.dispatches[0]; - assert.equal(inputs.candidate_run_id, CANDIDATE_RUN_ID); - assert.equal(inputs.qualification_run_id, QUALIFICATION_RUN_ID); - assert.equal(inputs.release_tag, 'v0.1.40'); - assert.equal(inputs.bridge_source_sha, BRIDGE_SHA); - assert.equal(inputs.publish_approved, 'true'); - assert.equal(gateway.dispatches[0].ref, DEFAULT_BRANCH); - assert.equal(plan.dispatchedRunId, '701'); - const qualificationReachabilityPath = `repos/${BRIDGE_REPOSITORY}/compare/${HEAD_SHA}...${DEFAULT_BRANCH}`; - assert.equal(gateway.apiPaths.filter((apiPath) => apiPath === qualificationReachabilityPath).length, 2); -})); - -test('test_attestation_bound_to_another_candidate_fails_closed', withAdvancePipelineFixture((self) => { - const candidateDir = path.join(self.tmp, 'candidate-src'); - writeBridgeCandidate(candidateDir, { - releaseTag: 'v0.1.40', releaseRebuild: 0, correlationId: self.correlationId, runId: CANDIDATE_RUN_ID, - }); - const otherDir = path.join(self.tmp, 'other-src'); - writeBridgeCandidate(otherDir, { - releaseTag: 'v0.1.40', - releaseRebuild: 0, - correlationId: self.correlationId, - runId: CANDIDATE_RUN_ID, - marker: Buffer.from('tampered'), - }); - const gateway = qualifiedGateway(self, candidateDir, otherDir); - assert.throws(() => driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }), ContractError); - assert.deepEqual(gateway.dispatches, []); -})); diff --git a/tests/release/orchestrator/driver_stale_observation_test.mjs b/tests/release/orchestrator/driver_stale_observation_test.mjs deleted file mode 100644 index 50f86d1..0000000 --- a/tests/release/orchestrator/driver_stale_observation_test.mjs +++ /dev/null @@ -1,673 +0,0 @@ -// Node-only tests of the dispatch correlation guard in -// scripts/release/orchestrator/driver.mjs, which has no Python twin. -// -// Scan 36232509530 (2026-09-26T09:21Z) planned from a stale candidate listing -// that omitted candidate run 36230600813 (created 08:42Z, succeeded 08:55Z), -// chose DISPATCH_CANDIDATE again, and was stopped only because the named-run -// guard rendered the identical name. Every dispatch now first reads its stage -// again by correlation, through the filtered history and the newest -// unfiltered page; a contradicting run makes the pipeline re-plan once with -// that evidence, and a re-plan that still wants the same stage (or meets a -// second stale listing) ends BLOCKED without dispatching or raising. - -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { test } from 'node:test'; - -import { BRIDGE_REPOSITORY, ContractError, NATIVE_REPOSITORY } from '../../../scripts/release/contract.mjs'; -import * as orchestrator from '../../../scripts/release/orchestrator/cli.mjs'; -import * as driver from '../../../scripts/release/orchestrator/driver.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as runNames from '../../../scripts/release/orchestrator/run_names.mjs'; -import * as workflowRuns from '../../../scripts/release/orchestrator/workflow_runs.mjs'; -import { ATTESTATION_ARTIFACT_NAME, CANDIDATE_ARTIFACT_NAME } from '../../../scripts/release/qualification.mjs'; -import { - ADVANCED_BRIDGE_SHA, BRIDGE_SHA, CANDIDATE_ARTIFACT_ID, CANDIDATE_RUN_ID, DEFAULT_BRANCH, FakeGateway, HEAD_SHA, - NATIVE_PUBLISHED_AT, QUALIFICATION_ARTIFACT_ID, makeProvenance, QUALIFICATION_RUN_ID, artifactInventory, assetReleaseStub, - directoryMembers, flatZip, runPayload, runsResponse, withAdvancePipelineFixture, writeBridgeCandidate, -} from './fixtures.mjs'; -import { attestationBytes, onDispatch, runsKey } from './driver_fixtures.mjs'; - -const RUNS = `repos/${BRIDGE_REPOSITORY}/actions/runs`; -const ARTIFACTS = `repos/${BRIDGE_REPOSITORY}/actions/artifacts`; -// The run the 09:21 scan's listing omitted. -const MISSED_RUN_ID = '36230600813'; -const { OrchestrationAction } = model; - -// A FakeGateway whose jsonRoutes are the server's true state, with stale -// answers served first for chosen paths: staleFor(path, response, times) -// answers `response` for the next `times` reads of `path` (Infinity for -// every read), then the true route. -class StaleGateway extends FakeGateway { - constructor(options) { - super(options); - this.staleAnswers = new Map(); - this.sequences = new Map(); - } - - staleFor(apiPath, response, times = 1) { - this.staleAnswers.set(apiPath, { response, remaining: times }); - return this; - } - - // Answer `responses` for the next reads of `apiPath`, one per read, then - // the true route. - sequence(apiPath, responses) { - this.sequences.set(apiPath, [...responses]); - return this; - } - - apiJson(apiPath, options) { - const queued = this.sequences.get(apiPath); - if (queued !== undefined && queued.length > 0) { - this.apiPaths.push(apiPath); - return queued.shift(); - } - const stale = this.staleAnswers.get(apiPath); - if (stale !== undefined && stale.remaining > 0) { - stale.remaining -= 1; - this.apiPaths.push(apiPath); - return stale.response; - } - return super.apiJson(apiPath, options); - } - - reads(apiPath) { - return this.apiPaths.filter((recorded) => recorded === apiPath).length; - } -} - -const CANDIDATE_LISTING = runsKey(model.CANDIDATE_WORKFLOW_FILE); -const QUALIFICATION_LISTING = runsKey(model.QUALIFICATION_WORKFLOW_FILE); -const PUBLISH_LISTING = runsKey(model.PUBLISH_WORKFLOW_FILE); -const RECENT_CANDIDATES = workflowRuns.recentWorkflowRunsPath(model.CANDIDATE_WORKFLOW_FILE); -const EMPTY = runsResponse([]); - -function qualificationName(self, candidateRunId = CANDIDATE_RUN_ID) { - return runNames.qualificationRunName(self.correlationId, candidateRunId); -} - -// The routes and blob a successful candidate run needs to be proven; the -// candidate artifact carries `binding`. -function provenCandidate(self, routes, blobs, { runId = CANDIDATE_RUN_ID, binding = self.binding } = {}) { - const candidateDir = path.join(self.tmp, `candidate-${runId}-${binding.releaseTag}-${binding.releaseRebuild}`); - writeBridgeCandidate(candidateDir, { - releaseTag: binding.releaseTag, - releaseRebuild: binding.releaseRebuild, - correlationId: self.correlationId, - bridgeCommit: binding.bridgeSourceSha, - runId, - }); - const run = runPayload({ - runId, path: model.CANDIDATE_WORKFLOW_PATH, runName: runNames.candidateRunName(self.correlationId, binding), - }); - routes[`${RUNS}/${runId}`] = run; - routes[`${RUNS}/${runId}/artifacts?per_page=100`] = artifactInventory({ - runId, name: CANDIDATE_ARTIFACT_NAME, artifactId: CANDIDATE_ARTIFACT_ID, - }); - routes[`repos/${BRIDGE_REPOSITORY}/compare/${HEAD_SHA}...${DEFAULT_BRANCH}`] = { status: 'identical' }; - blobs[`${ARTIFACTS}/${CANDIDATE_ARTIFACT_ID}/zip`] = flatZip(directoryMembers(candidateDir)); - return { run, candidateDir }; -} - -// The 09:21 state: candidate `runId` succeeded, qualification runs as given, -// and a candidate listing that omits the candidate on its first read. -function staleCandidateGateway(self, { qualificationRuns = [], runId = CANDIDATE_RUN_ID, binding = self.binding } = {}) { - const routes = {}; - const blobs = {}; - const { run } = provenCandidate(self, routes, blobs, { runId, binding }); - Object.assign(routes, self.routes({ releases: [assetReleaseStub()], candidateRuns: [run], qualificationRuns })); - return new StaleGateway({ jsonRoutes: routes, blobRoutes: blobs }).staleFor(CANDIDATE_LISTING, EMPTY); -} - -// The routes and blobs of a proven candidate and its successful qualification -// (driver_publication_test's qualifiedGateway), with publish runs as given. -function qualifiedGateway(self, publishRuns) { - const routes = {}; - const blobs = {}; - const { run: candidateRun, candidateDir } = provenCandidate(self, routes, blobs); - const qualificationRun = runPayload({ - runId: QUALIFICATION_RUN_ID, path: model.QUALIFICATION_WORKFLOW_PATH, runName: qualificationName(self), - }); - Object.assign(routes, self.routes({ - releases: [assetReleaseStub()], candidateRuns: [candidateRun], qualificationRuns: [qualificationRun], publishRuns, - })); - routes[`${RUNS}/${CANDIDATE_RUN_ID}`] = candidateRun; - routes[`${RUNS}/${QUALIFICATION_RUN_ID}`] = qualificationRun; - routes[`${RUNS}/${QUALIFICATION_RUN_ID}/artifacts?per_page=100`] = artifactInventory({ - runId: QUALIFICATION_RUN_ID, name: ATTESTATION_ARTIFACT_NAME, artifactId: QUALIFICATION_ARTIFACT_ID, - }); - blobs[`${ARTIFACTS}/${QUALIFICATION_ARTIFACT_ID}/zip`] = flatZip({ 'qualification-attestation.json': attestationBytes(candidateDir) }); - return new StaleGateway({ jsonRoutes: routes, blobRoutes: blobs }); -} - -function publishName(self, qualificationRunId = QUALIFICATION_RUN_ID) { - return runNames.publishRunName(self.correlationId, CANDIDATE_RUN_ID, qualificationRunId, self.binding); -} - -function assertReplanned(plan, runDescription) { - assert.ok(plan.reason.includes('(re-planned once: '), plan.reason); - assert.ok(plan.reason.includes(runDescription), plan.reason); -} - -// --- The 09:21 scan --------------------------------------------------------------- - -test('stale listing that omits a successful candidate re-plans instead of dispatching a duplicate', withAdvancePipelineFixture((self) => { - const qualification = runPayload({ - runId: '36231275207', path: model.QUALIFICATION_WORKFLOW_PATH, runName: qualificationName(self, MISSED_RUN_ID), - status: 'in_progress', conclusion: null, - }); - const gateway = staleCandidateGateway(self, { runId: MISSED_RUN_ID, qualificationRuns: [qualification] }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.deepEqual(gateway.dispatches, []); - assert.equal(plan.action, OrchestrationAction.IN_FLIGHT); - assert.equal(plan.candidateRunId, MISSED_RUN_ID); - assert.equal(plan.inFlightRunId, '36231275207'); - assertReplanned(plan, `bridge_candidate.yml run(s) ${MISSED_RUN_ID} (success)`); - assert.ok(plan.reason.includes('planned dispatch_candidate from was stale'), plan.reason); - // The planning read, the guard's fresh history read, and the re-plan's read. - assert.equal(gateway.reads(CANDIDATE_LISTING), 3); - assert.equal(gateway.reads(RECENT_CANDIDATES), 1); -})); - -test('stale candidate listing re-plans to the qualification dispatch that is the next step', withAdvancePipelineFixture((self) => { - const gateway = staleCandidateGateway(self); - const dispatched = runPayload({ - runId: '4201', path: model.QUALIFICATION_WORKFLOW_PATH, runName: qualificationName(self), status: 'in_progress', conclusion: null, - }); - onDispatch(gateway, () => { - gateway.jsonRoutes[QUALIFICATION_LISTING] = runsResponse([dispatched]); - }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.deepEqual(gateway.dispatches.map((record) => record.workflowFile), [model.QUALIFICATION_WORKFLOW_FILE]); - assert.deepEqual(gateway.dispatches[0].inputs, { - orchestrator_correlation_id: self.correlationId, - candidate_run_id: CANDIDATE_RUN_ID, - }); - assert.equal(plan.action, OrchestrationAction.DISPATCH_QUALIFICATION); - assert.equal(plan.dispatchedRunId, '4201'); - assertReplanned(plan, `${CANDIDATE_RUN_ID} (success)`); -})); - -test('dry run applies the correlation guard before reporting a dispatch', withAdvancePipelineFixture((self) => { - const gateway = staleCandidateGateway(self); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp, dryRun: true }); - assert.deepEqual(gateway.dispatches, []); - assert.equal(plan.action, OrchestrationAction.DISPATCH_QUALIFICATION); - assert.equal(plan.dispatchedRunId, null); - assert.equal(plan.candidateRunId, CANDIDATE_RUN_ID); -})); - -test('the backlog scan records the re-planned outcome without an error', withAdvancePipelineFixture(async (self) => { - const qualification = runPayload({ - runId: '4201', path: model.QUALIFICATION_WORKFLOW_PATH, runName: qualificationName(self), status: 'in_progress', conclusion: null, - }); - const gateway = staleCandidateGateway(self, { qualificationRuns: [qualification] }); - const [result, backlog] = await self.runBacklog(gateway, [self.provenance]); - assert.equal(result, 0); - assert.deepEqual(backlog.errors, []); - assert.deepEqual(backlog.plans.map((plan) => plan.action), [OrchestrationAction.IN_FLIGHT]); - assert.deepEqual(gateway.dispatches, []); -}, { orchestrator })); - -// --- A missed candidate under another run name --------------------------------- - -const OTHER_BINDINGS = Object.freeze({ - 'another tag': { releaseTag: 'v0.1.41', releaseRebuild: 0 }, - 'another rebuild': { releaseTag: 'v0.1.40-1', releaseRebuild: 1 }, - 'another source commit': { bridgeSourceSha: ADVANCED_BRIDGE_SHA }, -}); - -for (const [label, overrides] of Object.entries(OTHER_BINDINGS)) { - test(`missed in-flight candidate with ${label} blocks the fresh dispatch the name guard would allow`, withAdvancePipelineFixture((self) => { - const binding = new model.PipelineBinding({ - bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.40', releaseRebuild: 0, ...overrides, - }); - const missedName = runNames.candidateRunName(self.correlationId, binding); - assert.notEqual(missedName, self.candidateName); - const missed = runPayload({ - runId: MISSED_RUN_ID, path: model.CANDIDATE_WORKFLOW_PATH, runName: missedName, status: 'in_progress', conclusion: null, - }); - const routes = self.routes({ releases: [assetReleaseStub()], candidateRuns: [missed] }); - // The named-run check alone finds nothing for the fresh v0.1.40 name. - assert.equal(workflowRuns.findNamedRun(new FakeGateway({ jsonRoutes: routes }), { - workflowFile: model.CANDIDATE_WORKFLOW_FILE, - workflowPath: model.CANDIDATE_WORKFLOW_PATH, - defaultBranch: DEFAULT_BRANCH, - createdSince: NATIVE_PUBLISHED_AT, - runName: self.candidateName, - }), null); - const gateway = new StaleGateway({ jsonRoutes: routes }).staleFor(CANDIDATE_LISTING, EMPTY); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.deepEqual(gateway.dispatches, []); - assert.equal(plan.action, OrchestrationAction.IN_FLIGHT); - assert.equal(plan.inFlightRunId, MISSED_RUN_ID); - assert.equal(plan.releaseTarget.releaseTag, binding.releaseTag); - assert.equal(plan.releaseTarget.releaseRebuild, binding.releaseRebuild); - assertReplanned(plan, `${MISSED_RUN_ID} (in_progress)`); - })); -} - -test('missed successful candidate with another tag is proven and advanced, not duplicated', withAdvancePipelineFixture((self) => { - const binding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.41', releaseRebuild: 0 }); - const qualification = runPayload({ - runId: '4201', path: model.QUALIFICATION_WORKFLOW_PATH, runName: qualificationName(self), status: 'in_progress', conclusion: null, - }); - const gateway = staleCandidateGateway(self, { binding, qualificationRuns: [qualification] }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.deepEqual(gateway.dispatches, []); - assert.equal(plan.action, OrchestrationAction.IN_FLIGHT); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.41'); - assert.equal(plan.inFlightRunId, '4201'); -})); - -test('missed in-flight candidate with the same name is waited for', withAdvancePipelineFixture((self) => { - const missed = runPayload({ - runId: MISSED_RUN_ID, path: model.CANDIDATE_WORKFLOW_PATH, runName: self.candidateName, status: 'queued', conclusion: null, - }); - const gateway = new StaleGateway({ jsonRoutes: self.routes({ releases: [assetReleaseStub()], candidateRuns: [missed] }) }) - .staleFor(CANDIDATE_LISTING, EMPTY); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.deepEqual(gateway.dispatches, []); - assert.equal(plan.action, OrchestrationAction.IN_FLIGHT); - assert.equal(plan.inFlightRunId, MISSED_RUN_ID); -})); - -// --- Failed runs keep their existing rules ---------------------------------------- - -test('missed failed candidate keeps automatic candidate retries disabled', withAdvancePipelineFixture((self) => { - const failed = runPayload({ - runId: MISSED_RUN_ID, path: model.CANDIDATE_WORKFLOW_PATH, runName: self.candidateName, conclusion: 'failure', - }); - const gateway = new StaleGateway({ jsonRoutes: self.routes({ releases: [assetReleaseStub()], candidateRuns: [failed] }) }) - .staleFor(CANDIDATE_LISTING, EMPTY); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.deepEqual(gateway.dispatches, []); - assert.equal(plan.action, OrchestrationAction.BLOCKED); - assert.ok(plan.reason.includes('automatic candidate retries are disabled'), plan.reason); - assertReplanned(plan, `${MISSED_RUN_ID} (failure)`); -})); - -test('missed failed qualification keeps automatic qualification retries disabled', withAdvancePipelineFixture((self) => { - const failed = runPayload({ - runId: '4201', path: model.QUALIFICATION_WORKFLOW_PATH, runName: qualificationName(self), conclusion: 'failure', - }); - const gateway = staleCandidateGateway(self, { qualificationRuns: [failed] }); - gateway.staleAnswers.clear(); - gateway.staleFor(QUALIFICATION_LISTING, EMPTY); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.deepEqual(gateway.dispatches, []); - assert.equal(plan.action, OrchestrationAction.BLOCKED); - assert.ok(plan.reason.includes('automatic qualification retries are disabled'), plan.reason); - assertReplanned(plan, 'bridge_qualification.yml run(s) 4201 (failure)'); -})); - -test('missed failed publication does not block the publication retry', withAdvancePipelineFixture((self) => { - const failed = runPayload({ runId: '700', path: model.PUBLISH_WORKFLOW_PATH, runName: publishName(self), conclusion: 'failure' }); - const gateway = qualifiedGateway(self, [failed]).staleFor(PUBLISH_LISTING, EMPTY); - const dispatched = runPayload({ - runId: '701', path: model.PUBLISH_WORKFLOW_PATH, runName: publishName(self), status: 'in_progress', conclusion: null, - }); - onDispatch(gateway, () => { - gateway.jsonRoutes[PUBLISH_LISTING] = runsResponse([failed, dispatched]); - }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.deepEqual(gateway.dispatches.map((record) => record.workflowFile), [model.PUBLISH_WORKFLOW_FILE]); - assert.equal(plan.action, OrchestrationAction.DISPATCH_PUBLISH); - assert.equal(plan.dispatchedRunId, '701'); - assert.ok(!plan.reason.includes('re-planned'), plan.reason); -})); - -// --- Qualification and publication stages ------------------------------------------- - -test('missed in-flight qualification is waited for', withAdvancePipelineFixture((self) => { - const qualification = runPayload({ - runId: '4201', path: model.QUALIFICATION_WORKFLOW_PATH, runName: qualificationName(self), status: 'in_progress', conclusion: null, - }); - const gateway = staleCandidateGateway(self, { qualificationRuns: [qualification] }); - gateway.staleAnswers.clear(); - gateway.staleFor(QUALIFICATION_LISTING, EMPTY); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.deepEqual(gateway.dispatches, []); - assert.equal(plan.action, OrchestrationAction.IN_FLIGHT); - assert.equal(plan.inFlightRunId, '4201'); - assertReplanned(plan, 'bridge_qualification.yml run(s) 4201 (in_progress)'); -})); - -test('missed in-flight publication is waited for', withAdvancePipelineFixture((self) => { - const inFlight = runPayload({ - runId: '701', path: model.PUBLISH_WORKFLOW_PATH, runName: publishName(self), status: 'in_progress', conclusion: null, - }); - const gateway = qualifiedGateway(self, [inFlight]).staleFor(PUBLISH_LISTING, EMPTY); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.deepEqual(gateway.dispatches, []); - assert.equal(plan.action, OrchestrationAction.IN_FLIGHT); - assert.equal(plan.inFlightRunId, '701'); - assertReplanned(plan, 'publish_assets.yml run(s) 701 (in_progress)'); -})); - -test('missed successful publication fails closed exactly as a fresh listing does', withAdvancePipelineFixture((self) => { - const succeeded = runPayload({ runId: '701', path: model.PUBLISH_WORKFLOW_PATH, runName: publishName(self) }); - const fresh = qualifiedGateway(self, [succeeded]); - const stale = qualifiedGateway(self, [succeeded]).staleFor(PUBLISH_LISTING, EMPTY); - const expected = /publication run 701 succeeded but no immutable release/u; - for (const [label, gateway] of [['fresh', fresh], ['stale', stale]]) { - const workspace = path.join(self.tmp, label); - fs.mkdirSync(workspace); - assert.throws(() => driver.advancePipeline(gateway, { provenance: self.provenance, workspace }), expected); - } - assert.deepEqual(stale.dispatches, []); -})); - -test('re-plan that still wants the same stage ends blocked without dispatching', withAdvancePipelineFixture((self) => { - // A publication of this exact candidate under another qualification run - // id: the correlation guard sees it, the exact-name observation does not. - const other = runPayload({ - runId: '701', path: model.PUBLISH_WORKFLOW_PATH, runName: publishName(self, '4999'), status: 'in_progress', conclusion: null, - }); - const gateway = qualifiedGateway(self, [other]); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.deepEqual(gateway.dispatches, []); - assert.equal(plan.action, OrchestrationAction.BLOCKED); - assert.equal(plan.candidateRunId, CANDIDATE_RUN_ID); - assert.equal(plan.qualificationRunId, QUALIFICATION_RUN_ID); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.40'); - assert.ok(plan.reason.includes('publish_assets.yml run(s) 701 (in_progress)'), plan.reason); - assert.ok(plan.reason.includes('the pipeline still planned dispatch_publish'), plan.reason); - assert.ok(plan.reason.includes('nothing was dispatched'), plan.reason); -})); - -test('a second stale listing on the next stage ends blocked after one re-plan', withAdvancePipelineFixture((self) => { - const qualification = runPayload({ - runId: '4201', path: model.QUALIFICATION_WORKFLOW_PATH, runName: qualificationName(self), status: 'in_progress', conclusion: null, - }); - const gateway = staleCandidateGateway(self, { qualificationRuns: [qualification] }) - .staleFor(QUALIFICATION_LISTING, EMPTY, Infinity); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.deepEqual(gateway.dispatches, []); - assert.equal(plan.action, OrchestrationAction.BLOCKED); - assert.ok(plan.reason.includes(`bridge_candidate.yml run(s) ${CANDIDATE_RUN_ID} (success)`), plan.reason); - assert.ok(plan.reason.includes('the pipeline planned dispatch_qualification'), plan.reason); - assert.ok(plan.reason.includes('bridge_qualification.yml run(s) 4201 (in_progress)'), plan.reason); -})); - -// --- The two query shapes ---------------------------------------------------------- - -test('the unfiltered newest page catches a run every filtered read omits', withAdvancePipelineFixture((self) => { - const qualification = runPayload({ - runId: '4201', path: model.QUALIFICATION_WORKFLOW_PATH, runName: qualificationName(self), status: 'in_progress', conclusion: null, - }); - const gateway = staleCandidateGateway(self, { qualificationRuns: [qualification] }) - .staleFor(CANDIDATE_LISTING, EMPTY, Infinity); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.deepEqual(gateway.dispatches, []); - // The re-plan's own listing is stale too; the merged evidence carries it. - assert.equal(plan.action, OrchestrationAction.IN_FLIGHT); - assert.equal(plan.candidateRunId, CANDIDATE_RUN_ID); -})); - -test('the named-run check still stops a duplicate both correlation reads miss', withAdvancePipelineFixture((self) => { - const missed = runPayload({ - runId: MISSED_RUN_ID, path: model.CANDIDATE_WORKFLOW_PATH, runName: self.candidateName, status: 'in_progress', conclusion: null, - }); - const gateway = new StaleGateway({ jsonRoutes: self.routes({ releases: [assetReleaseStub()], candidateRuns: [missed] }) }) - .staleFor(CANDIDATE_LISTING, EMPTY, 2) - .staleFor(RECENT_CANDIDATES, EMPTY); - assert.throws( - () => driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }), - new ContractError(`a run named '${self.candidateName}' already exists; refusing to dispatch a duplicate`), - ); - assert.deepEqual(gateway.dispatches, []); -})); - -test('newest unfiltered page keeps pipeline runs and fails closed on a correlated off-filter run', () => { - const correlationId = runNames.computeCorrelationId(makeProvenance()); - const other = runNames.computeCorrelationId(makeProvenance({ nativeReleaseTag: 'v0.2.1', upstreamTag: 'v0.2.1' })); - const binding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.40', releaseRebuild: 0 }); - const name = runNames.candidateRunName(correlationId, binding); - const otherName = runNames.candidateRunName(other, binding); - const workflowPath = model.CANDIDATE_WORKFLOW_PATH; - const read = (runs, { totalCount = runs.length, select = () => false } = {}) => workflowRuns.fetchRecentRuns( - new FakeGateway({ - jsonRoutes: { [RECENT_CANDIDATES]: { total_count: totalCount, workflow_runs: runs } }, - }), - { - workflowFile: model.CANDIDATE_WORKFLOW_FILE, - workflowPath, - defaultBranch: DEFAULT_BRANCH, - correlated: workflowRuns.candidateMatcher(correlationId), - select, - }, - ); - const kept = runPayload({ runId: '9', path: workflowPath, runName: name }); - const otherKept = runPayload({ runId: '4', path: workflowPath, runName: otherName }); - // Unrelated runs the server filter drops are skipped unparsed, as are - // another pipeline's runs outside it even when selected. - const skipped = [ - { ...runPayload({ runId: '8', path: workflowPath, runName: 'Merge pull request #7', event: 'push' }), actor: { login: 'someone' } }, - runPayload({ runId: '7', path: workflowPath, runName: 'Publish Exact Qualified Bridge Assets', actor: 'github-actions[bot]' }), - runPayload({ runId: '6', path: workflowPath, runName: otherName, headBranch: 'topic' }), - runPayload({ runId: '5', path: workflowPath, runName: otherName, actor: 'github-actions[bot]' }), - ]; - assert.deepEqual(read([kept, otherKept, ...skipped]).map((record) => record.runId), ['9']); - assert.deepEqual( - read([kept, otherKept, ...skipped], { select: (title) => title === otherName }).map((record) => record.runId), - ['9', '4'], - ); - // Only the newest page is read; a longer history is not an error. - const older = Array.from({ length: 99 }, (_, index) => runPayload({ - runId: String(1000 + index), path: workflowPath, runName: `v${index}`, event: 'push', - })); - assert.deepEqual(read([kept, ...older], { totalCount: 250 }).map((record) => record.runId), ['9']); - assert.throws(() => read([kept], { totalCount: 2 }), /newest workflow run page .* has 1 records, expected 2/u); - assert.throws(() => read([kept, kept]), /listed more than once/u); - // A run named for the guarded pipeline must be exactly what the server - // filter selects, and parse strictly. - const offFilter = /workflow run 9 is named for this pipeline but is not a workflow_dispatch run by leehack on 'main'/u; - assert.throws(() => read([runPayload({ runId: '9', path: workflowPath, runName: name, headBranch: 'topic' })]), offFilter); - assert.throws(() => read([runPayload({ runId: '9', path: workflowPath, runName: name, event: 'push' })]), offFilter); - assert.throws(() => read([runPayload({ runId: '9', path: workflowPath, runName: name, actor: 'github-actions[bot]' })]), offFilter); - assert.throws(() => read([runPayload({ runId: '9', path: workflowPath, runName: name, triggeringActor: 'someone' })]), ContractError); -}); - -test('fresh reads of one run merge to its later state or fail closed', () => { - const record = (overrides) => new workflowRuns.RunRecord({ - runId: '9', - runName: 'n', - status: 'completed', - conclusion: 'success', - headBranch: DEFAULT_BRANCH, - headSha: HEAD_SHA, - runAttempt: 1, - ...overrides, - }); - const live = record({ status: 'in_progress', conclusion: null }); - const done = record({}); - assert.deepEqual(workflowRuns.mergeRunRecords([live], [done]), [done]); - assert.deepEqual(workflowRuns.mergeRunRecords([done], [live]), [done]); - assert.deepEqual(workflowRuns.mergeRunRecords([record({ status: 'queued', conclusion: null })], [live]), [live]); - const rerun = record({ status: 'in_progress', conclusion: null, runAttempt: 2 }); - assert.deepEqual(workflowRuns.mergeRunRecords([done], [rerun]), [rerun]); - assert.deepEqual(workflowRuns.mergeRunRecords([rerun], [done]), [rerun]); - assert.throws(() => workflowRuns.mergeRunRecords([done], [record({ conclusion: 'failure' })]), /contradictory outcomes/u); - assert.throws(() => workflowRuns.mergeRunRecords([done], [record({ runName: 'other' })]), /contradictory identities/u); -}); - -// --- Output tags another pipeline still claims ------------------------------------ - -const TAG_V0140 = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.40', releaseRebuild: 0 }); -const TAG_V0141 = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.41', releaseRebuild: 0 }); -const OTHER_BUILD_SHA = 'b'.repeat(40); -const RECENT_QUALIFICATIONS = workflowRuns.recentWorkflowRunsPath(model.QUALIFICATION_WORKFLOW_FILE); - -// Another pipeline's candidate run claiming `binding`'s tag: an earlier native -// release of this build, the next one when `newerNative`, or this native -// release of another build. -function claimRun(self, { - runId = '401', binding = TAG_V0140, otherBuild = false, newerNative = false, status = 'in_progress', -} = {}) { - let claimant = makeProvenance({ nativeReleaseTag: 'v0.1.9' }); - if (otherBuild) claimant = makeProvenance({ bridgeBuildSha: OTHER_BUILD_SHA }); - if (newerNative) claimant = self.newerNative(); - const correlationId = runNames.computeCorrelationId(claimant); - const run = runPayload({ - runId, - path: model.CANDIDATE_WORKFLOW_PATH, - runName: runNames.candidateRunName(correlationId, binding), - status, - conclusion: status === 'completed' ? 'success' : null, - }); - return { correlationId, run }; -} - -// A first-run candidate plan built from a listing that omits `claims`. -function staleClaimGateway(self, claims, { releases = [assetReleaseStub()], qualificationRuns = [] } = {}) { - return new StaleGateway({ - jsonRoutes: self.routes({ releases, candidateRuns: claims, qualificationRuns }), - }).staleFor(CANDIDATE_LISTING, EMPTY); -} - -function dryRun(self, gateway) { - return driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp, dryRun: true }); -} - -test('stale listing that omits an in-flight claim on the fresh tag re-plans to the next free tag', withAdvancePipelineFixture((self) => { - const { correlationId, run } = claimRun(self); - const gateway = staleClaimGateway(self, [run]); - const plan = dryRun(self, gateway); - assert.equal(plan.action, OrchestrationAction.DISPATCH_CANDIDATE); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.41'); - assert.equal(plan.dispatchInputs.release_tag, 'v0.1.41'); - assertReplanned(plan, `401 (in_progress, claiming v0.1.40 for ${correlationId})`); - assert.deepEqual(gateway.dispatches, []); -})); - -test('the re-planned candidate dispatches once with the next free tag', withAdvancePipelineFixture((self) => { - const { run } = claimRun(self); - const gateway = staleClaimGateway(self, [run]); - const ownName = runNames.candidateRunName(self.correlationId, TAG_V0141); - onDispatch(gateway, () => { - gateway.staleAnswers.clear(); - gateway.jsonRoutes[CANDIDATE_LISTING] = runsResponse([run, runPayload({ - runId: '501', path: model.CANDIDATE_WORKFLOW_PATH, runName: ownName, status: 'in_progress', conclusion: null, - })]); - }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.deepEqual(gateway.dispatches.map((record) => record.inputs.release_tag), ['v0.1.41']); - assert.equal(plan.dispatchedRunId, '501'); -})); - -test('a claim of another build still counts while its qualification runs', withAdvancePipelineFixture((self) => { - const { correlationId, run } = claimRun(self, { otherBuild: true, status: 'completed' }); - const qualification = runPayload({ - runId: '402', - path: model.QUALIFICATION_WORKFLOW_PATH, - runName: runNames.qualificationRunName(correlationId, '401'), - status: 'in_progress', - conclusion: null, - }); - const gateway = staleClaimGateway(self, [run], { qualificationRuns: [qualification] }); - const plan = dryRun(self, gateway); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.41'); - assertReplanned(plan, `401 (success, claiming v0.1.40 for ${correlationId})`); -})); - -test('a completed claim of this build holds until its tag is published', withAdvancePipelineFixture((self) => { - const { correlationId, run } = claimRun(self, { status: 'completed' }); - const plan = dryRun(self, staleClaimGateway(self, [run])); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.41'); - assertReplanned(plan, `401 (success, claiming v0.1.40 for ${correlationId})`); -})); - -test('a stale-omitted completed claim whose tag is published does not re-plan', withAdvancePipelineFixture((self) => { - const { run } = claimRun(self, { status: 'completed' }); - const gateway = staleClaimGateway(self, [run], { releases: [assetReleaseStub(), assetReleaseStub('v0.1.40')] }); - const plan = dryRun(self, gateway); - // The published tag set the floor, so the fresh tag is already above it. - assert.equal(plan.action, OrchestrationAction.DISPATCH_CANDIDATE); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.41'); - assert.ok(!plan.reason.includes('re-planned'), plan.reason); - assert.equal(gateway.reads(RECENT_QUALIFICATIONS), 0); -})); - -test('a released claim of another build does not block its tag', withAdvancePipelineFixture((self) => { - const { run } = claimRun(self, { otherBuild: true, status: 'completed' }); - const gateway = staleClaimGateway(self, [run]); - const plan = dryRun(self, gateway); - assert.equal(plan.action, OrchestrationAction.DISPATCH_CANDIDATE); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.40'); - assert.ok(!plan.reason.includes('re-planned'), plan.reason); - // The release was decided from fresh downstream reads, not assumed. - assert.equal(gateway.reads(RECENT_QUALIFICATIONS), 1); -})); - -test('stale listing that omits a newer native claim below the fresh tag re-plans to superseded', withAdvancePipelineFixture((self) => { - const listed = claimRun(self, { runId: '402', binding: TAG_V0141 }); - const { correlationId, run } = claimRun(self, { newerNative: true }); - const newer = self.newerNative(); - const gateway = new StaleGateway({ - jsonRoutes: { - ...self.routes({ releases: [assetReleaseStub()], candidateRuns: [listed.run, run] }), - [`repos/${NATIVE_REPOSITORY}/releases?per_page=100`]: [[{ - tag_name: newer.nativeReleaseTag, - draft: false, - prerelease: false, - assets: [{ name: 'assets.json', digest: `sha256:${newer.nativeManifestSha256}` }], - }]], - }, - }).staleFor(CANDIDATE_LISTING, runsResponse([listed.run])); - const plan = dryRun(self, gateway); - assert.equal(plan.action, OrchestrationAction.SUPERSEDED); - assert.equal(plan.dispatchWorkflow, null); - assertReplanned(plan, `401 (in_progress, claiming v0.1.40 for ${correlationId})`); - assert.deepEqual(gateway.dispatches, []); -})); - -test('a claim above the fresh tag re-plans too', withAdvancePipelineFixture((self) => { - const { correlationId, run } = claimRun(self, { binding: TAG_V0141 }); - const plan = dryRun(self, staleClaimGateway(self, [run])); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.42'); - assertReplanned(plan, `401 (in_progress, claiming v0.1.41 for ${correlationId})`); -})); - -test('a tag still claimed after the re-plan ends blocked without dispatching', withAdvancePipelineFixture((self) => { - const first = claimRun(self); - const second = claimRun(self, { runId: '403', binding: TAG_V0141, otherBuild: true }); - // The filtered listing stays stale for the re-plan, and the second claim - // appears only after the first guard's reads. - const gateway = new StaleGateway({ jsonRoutes: self.routes({ releases: [assetReleaseStub()], candidateRuns: [first.run, second.run] }) }) - .sequence(CANDIDATE_LISTING, [EMPTY, runsResponse([first.run]), EMPTY]) - .sequence(RECENT_CANDIDATES, [runsResponse([first.run])]); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.deepEqual(gateway.dispatches, []); - assert.equal(plan.action, OrchestrationAction.BLOCKED); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.41'); - assert.ok(plan.reason.includes(`401 (in_progress, claiming v0.1.40 for ${first.correlationId})`), plan.reason); - assert.ok(plan.reason.includes(`403 (in_progress, claiming v0.1.41 for ${second.correlationId})`), plan.reason); -})); - -test('stale evidence refuses a re-plan whose tag its claims still reach', () => { - const provenance = makeProvenance(); - const correlationId = runNames.computeCorrelationId(provenance); - const record = new workflowRuns.RunRecord({ - runId: '401', runName: 'n', status: 'in_progress', conclusion: null, headBranch: DEFAULT_BRANCH, headSha: HEAD_SHA, runAttempt: 1, - }); - const plan = (releaseTag) => new model.OrchestrationPlan({ - action: OrchestrationAction.DISPATCH_CANDIDATE, - reason: 'r', - provenance, - correlationId, - releaseTarget: new model.ReleaseTarget({ releaseTag, releaseRebuild: 0 }), - dispatchWorkflow: model.CANDIDATE_WORKFLOW_FILE, - }); - const stale = new driver.StaleObservation({ - workflowFile: model.CANDIDATE_WORKFLOW_FILE, - plan: plan('v0.1.40'), - evidence: new Map(), - claims: [{ record, releaseTag: 'v0.1.41-1', correlationId: 'other' }], - }); - assert.equal(stale.refuses(plan('v0.1.40')), true); - assert.equal(stale.refuses(plan('v0.1.41')), true); - assert.equal(stale.refuses(plan('v0.1.42')), false); -}); diff --git a/tests/release/orchestrator/driver_test.mjs b/tests/release/orchestrator/driver_test.mjs deleted file mode 100644 index 4bbbc7a..0000000 --- a/tests/release/orchestrator/driver_test.mjs +++ /dev/null @@ -1,360 +0,0 @@ -// Tests of scripts/release/orchestrator/driver.mjs: governance and dispatch -// identity, and the candidate stage of advancePipeline. One test per test -// method of scripts/release_orchestrator_driver_test.py, with the same names -// and assertions; the other stages are in driver__test.mjs. - -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { test } from 'node:test'; - -import { ASSETS_REPOSITORY, BRIDGE_REPOSITORY, ContractError } from '../../../scripts/release/contract.mjs'; -import { pyIntFromString } from '../../../scripts/release/json.mjs'; -import * as driver from '../../../scripts/release/orchestrator/driver.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as planner from '../../../scripts/release/orchestrator/planner.mjs'; -import * as runNames from '../../../scripts/release/orchestrator/run_names.mjs'; -import { CANDIDATE_ARTIFACT_NAME } from '../../../scripts/release/qualification.mjs'; -import { - ADVANCED_BRIDGE_SHA, ASSETS_TAG_COMMIT, BRIDGE_SHA, CANDIDATE_ARTIFACT_ID, CANDIDATE_RUN_ID, DEFAULT_BRANCH, FakeGateway, - HEAD_SHA, QUALIFICATION_RUN_ID, artifactInventory, assetReleaseStub, directoryMembers, flatZip, - makeProvenance, runPayload, runsResponse, withAdvancePipelineFixture, writeBridgeCandidate, -} from './fixtures.mjs'; -import { onDispatch, runsKey } from './driver_fixtures.mjs'; - -// --- GovernanceAndDispatchIdentityTest --------------------------------------------- - -function governanceSetUp() { - const provenance = makeProvenance(); - return { - provenance, - correlationId: runNames.computeCorrelationId(provenance), - gateway({ jsonRoutes = {}, ...options } = {}) { - const routes = { - [`repos/${ASSETS_REPOSITORY}/releases?per_page=100`]: [[]], - [runsKey(model.CANDIDATE_WORKFLOW_FILE)]: runsResponse([]), - ...jsonRoutes, - }; - return new FakeGateway({ jsonRoutes: routes, ...options }); - }, - }; -} - -function withWorkspace(fn) { - const workspace = fs.mkdtempSync(path.join(tmpdir(), 'sro-driver-')); - try { - return fn(workspace); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } -} - -test('test_disabled_immutable_release_governance_fails_closed', () => { - const gateway = governanceSetUp().gateway({ governance: { enabled: false, enforced_by_owner: false } }); - assert.throws(() => driver.requireImmutableReleaseGovernance(gateway), ContractError); -}); - -test('test_malformed_governance_response_fails_closed', () => { - const gateway = governanceSetUp().gateway({ governance: { enabled: true } }); - assert.throws(() => driver.requireImmutableReleaseGovernance(gateway), ContractError); -}); - -test('test_live_governance_is_proven_not_asserted', () => { - const proven = driver.requireImmutableReleaseGovernance(governanceSetUp().gateway()); - assert.equal(proven.repository, ASSETS_REPOSITORY); - assert.equal(proven.enabled, true); -}); - -test('test_absent_dispatch_identity_blocks_without_dispatching', () => { - const self = governanceSetUp(); - const gateway = self.gateway({ identity: null }); - const plan = withWorkspace((workspace) => driver.advancePipeline(gateway, { provenance: self.provenance, workspace })); - assert.equal(plan.action, model.OrchestrationAction.BLOCKED); - assert.deepEqual(gateway.dispatches, []); - assert.ok(plan.reason.toLowerCase().includes('dispatch identity')); -}); - -test('test_non_owner_dispatch_identity_blocks_without_dispatching', () => { - const self = governanceSetUp(); - const gateway = self.gateway({ identity: 'someone-else' }); - const plan = withWorkspace((workspace) => driver.advancePipeline(gateway, { provenance: self.provenance, workspace })); - assert.equal(plan.action, model.OrchestrationAction.BLOCKED); - assert.deepEqual(gateway.dispatches, []); -}); - -test('test_publish_approval_requires_live_environment_policy', () => { - const self = governanceSetUp(); - const binding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.40', releaseRebuild: 0 }); - const plan = planner.planPipeline({ - provenance: self.provenance, - correlationId: self.correlationId, - observation: new model.PipelineObservation({ - binding, - candidateRunId: CANDIDATE_RUN_ID, - qualificationRunId: QUALIFICATION_RUN_ID, - }), - }); - const gateway = self.gateway(); - gateway.jsonRoutes[`repos/${BRIDGE_REPOSITORY}/environments/bridge-assets-publication`] = { - name: 'bridge-assets-publication', - can_admins_bypass: true, - protection_rules: [{ type: 'branch_policy' }], - deployment_branch_policy: { protected_branches: false, custom_branch_policies: true }, - }; - assert.throws(() => driver.executeDispatch(gateway, plan, { - defaultBranch: DEFAULT_BRANCH, - workflowPath: model.PUBLISH_WORKFLOW_PATH, - dryRun: true, - }), ContractError); - assert.deepEqual(gateway.dispatches, []); -}); - -// --- AdvancePipelineTest: candidate stage ------------------------------------------ - -test('test_first_run_dispatches_exactly_one_candidate_with_structured_inputs', withAdvancePipelineFixture((self) => { - const dispatched = runPayload({ - runId: '501', path: model.CANDIDATE_WORKFLOW_PATH, runName: self.candidateName, status: 'in_progress', conclusion: null, - }); - const gateway = new FakeGateway({ jsonRoutes: self.routes({ releases: [assetReleaseStub()] }) }); - const expectedBinding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.40', releaseRebuild: 0 }); - const readbackKey = runsKey(model.CANDIDATE_WORKFLOW_FILE); - onDispatch(gateway, () => { - gateway.jsonRoutes[readbackKey] = runsResponse([dispatched]); - }); - - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.DISPATCH_CANDIDATE); - assert.equal(gateway.dispatches.length, 1); - const record = gateway.dispatches[0]; - assert.equal(record.workflowFile, model.CANDIDATE_WORKFLOW_FILE); - assert.equal(record.ref, DEFAULT_BRANCH); - assert.equal(record.inputs.release_tag, 'v0.1.40'); - assert.ok(Object.values(record.inputs).every((value) => typeof value === 'string')); - assert.equal(plan.dispatchedRunId, '501'); - assert.equal(runNames.candidateRunName(self.correlationId, expectedBinding), self.candidateName); -})); - -// _dispatch_with_other_build_claim. -function dispatchWithOtherBuildClaim(self, { qualificationStatus }) { - const other = runNames.computeCorrelationId(makeProvenance({ bridgeBuildSha: 'b'.repeat(40) })); - const orphan = runPayload({ - runId: '401', path: model.CANDIDATE_WORKFLOW_PATH, runName: runNames.candidateRunName(other, self.binding), - }); - const qualification = runPayload({ - runId: '402', - path: model.QUALIFICATION_WORKFLOW_PATH, - runName: runNames.qualificationRunName(other, '401'), - status: qualificationStatus, - conclusion: qualificationStatus !== 'completed' ? null : 'failure', - }); - const gateway = new FakeGateway({ - jsonRoutes: self.routes({ releases: [assetReleaseStub()], candidateRuns: [orphan], qualificationRuns: [qualification] }), - }); - const readbackKey = runsKey(model.CANDIDATE_WORKFLOW_FILE); - onDispatch(gateway, ({ inputs }) => { - gateway.jsonRoutes[readbackKey] = runsResponse([ - orphan, - runPayload({ - runId: '501', - path: model.CANDIDATE_WORKFLOW_PATH, - runName: ( - `bridge-candidate ${self.correlationId}` - + ` source:${BRIDGE_SHA}` - + ` tag:${inputs.release_tag}` - + ` rebuild:${inputs.release_rebuild}` - ), - status: 'in_progress', - conclusion: null, - }), - ]); - }); - driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - return gateway; -} - -test('test_finished_pipeline_of_another_build_does_not_force_a_rebuild_tag', withAdvancePipelineFixture((self) => { - const { inputs } = dispatchWithOtherBuildClaim(self, { qualificationStatus: 'completed' }).dispatches[0]; - assert.equal(inputs.release_tag, 'v0.1.40'); - assert.equal(inputs.release_rebuild, '0'); -})); - -test('test_in_flight_pipeline_of_another_build_still_reserves_its_tag', withAdvancePipelineFixture((self) => { - const { inputs } = dispatchWithOtherBuildClaim(self, { qualificationStatus: 'in_progress' }).dispatches[0]; - assert.equal(inputs.release_tag, 'v0.1.41'); - assert.equal(inputs.release_rebuild, '0'); -})); - -test('test_in_flight_candidate_produces_no_second_dispatch', withAdvancePipelineFixture((self) => { - const inFlight = runPayload({ - runId: '501', path: model.CANDIDATE_WORKFLOW_PATH, runName: self.candidateName, status: 'in_progress', conclusion: null, - }); - const gateway = new FakeGateway({ jsonRoutes: self.routes({ candidateRuns: [inFlight] }) }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.IN_FLIGHT); - assert.deepEqual(gateway.dispatches, []); -})); - -test('test_failed_candidate_is_terminal_without_daily_duplicate', withAdvancePipelineFixture((self) => { - const failed = runPayload({ - runId: '501', path: model.CANDIDATE_WORKFLOW_PATH, runName: self.candidateName, status: 'completed', conclusion: 'failure', - }); - // Main advancing cannot silently turn the same native provenance into a - // fresh candidate attempt. - const advanced = makeProvenance({ bridgeSourceSha: ADVANCED_BRIDGE_SHA }); - const gateway = new FakeGateway({ jsonRoutes: self.routes({ releases: [assetReleaseStub()], candidateRuns: [failed] }) }); - const plan = driver.advancePipeline(gateway, { provenance: advanced, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.BLOCKED); - assert.deepEqual(gateway.dispatches, []); - assert.ok(plan.reason.includes('automatic candidate retries are disabled')); -})); - -// The routes a succeeded candidate run needs to be proven. -function succeededCandidateRoutes(self, routes, succeeded, extraArtifacts = null) { - routes[`repos/${BRIDGE_REPOSITORY}/actions/runs/${CANDIDATE_RUN_ID}`] = succeeded; - routes[`repos/${BRIDGE_REPOSITORY}/actions/runs/${CANDIDATE_RUN_ID}/artifacts?per_page=100`] = artifactInventory({ - runId: CANDIDATE_RUN_ID, name: CANDIDATE_ARTIFACT_NAME, artifactId: CANDIDATE_ARTIFACT_ID, extra: extraArtifacts, - }); - routes[`repos/${BRIDGE_REPOSITORY}/compare/${HEAD_SHA}...${DEFAULT_BRANCH}`] = { status: 'identical' }; - return routes; -} - -test('test_deliberate_success_after_failed_candidate_recovers_pipeline', withAdvancePipelineFixture((self) => { - const candidateDir = path.join(self.tmp, 'candidate-after-failure'); - writeBridgeCandidate(candidateDir, { - releaseTag: 'v0.1.40', releaseRebuild: 0, correlationId: self.correlationId, runId: CANDIDATE_RUN_ID, - }); - const failed = runPayload({ - runId: '501', path: model.CANDIDATE_WORKFLOW_PATH, runName: self.candidateName, status: 'completed', conclusion: 'failure', - }); - const succeeded = runPayload({ runId: CANDIDATE_RUN_ID, path: model.CANDIDATE_WORKFLOW_PATH, runName: self.candidateName }); - const routes = succeededCandidateRoutes(self, self.routes({ candidateRuns: [failed, succeeded] }), succeeded); - const gateway = new FakeGateway({ - jsonRoutes: routes, - blobRoutes: { - [`repos/${BRIDGE_REPOSITORY}/actions/artifacts/${CANDIDATE_ARTIFACT_ID}/zip`]: flatZip(directoryMembers(candidateDir)), - }, - }); - const dispatched = runPayload({ - runId: '4201', - path: model.QUALIFICATION_WORKFLOW_PATH, - runName: runNames.qualificationRunName(self.correlationId, CANDIDATE_RUN_ID), - status: 'in_progress', - conclusion: null, - }); - const readbackKey = runsKey(model.QUALIFICATION_WORKFLOW_FILE); - onDispatch(gateway, () => { - gateway.jsonRoutes[readbackKey] = runsResponse([dispatched]); - }); - - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - assert.equal(plan.action, model.OrchestrationAction.DISPATCH_QUALIFICATION); - assert.equal(plan.candidateRunId, CANDIDATE_RUN_ID); - assert.equal(plan.dispatchedRunId, '4201'); - assert.deepEqual(gateway.dispatches.map((record) => record.workflowFile), [model.QUALIFICATION_WORKFLOW_FILE]); -})); - -test('test_dry_run_plans_without_dispatching', withAdvancePipelineFixture((self) => { - const gateway = new FakeGateway({ jsonRoutes: self.routes({ releases: [assetReleaseStub()] }) }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp, dryRun: true }); - assert.equal(plan.action, model.OrchestrationAction.DISPATCH_CANDIDATE); - assert.deepEqual(gateway.dispatches, []); - assert.equal(plan.dispatchedRunId, null); - // A dry run must still report the exact ref and inputs it would use. - assert.equal(plan.dispatchRef, DEFAULT_BRANCH); - assert.equal(plan.dispatchInputs.assets_immutable_releases_enabled, 'true'); -})); - -test('test_orphan_assets_tag_ref_is_reserved_before_candidate_dispatch', withAdvancePipelineFixture((self) => { - const routes = self.routes({ releases: [assetReleaseStub()] }); - routes[`repos/${ASSETS_REPOSITORY}/git/matching-refs/tags?per_page=100`] = [[ - { ref: 'refs/tags/v0.1.40', object: { type: 'commit', sha: ASSETS_TAG_COMMIT } }, - ]]; - const gateway = new FakeGateway({ jsonRoutes: routes }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp, dryRun: true }); - assert.equal(plan.releaseTarget.releaseTag, 'v0.1.41'); - assert.equal(plan.releaseTarget.releaseRebuild, 0); -})); - -test('test_candidate_dispatch_inputs_carry_the_live_governance_proof', withAdvancePipelineFixture((self) => { - const dispatched = runPayload({ - runId: '501', path: model.CANDIDATE_WORKFLOW_PATH, runName: self.candidateName, status: 'in_progress', conclusion: null, - }); - const gateway = new FakeGateway({ jsonRoutes: self.routes({ releases: [assetReleaseStub()] }) }); - const readbackKey = runsKey(model.CANDIDATE_WORKFLOW_FILE); - onDispatch(gateway, () => { - gateway.jsonRoutes[readbackKey] = runsResponse([dispatched]); - }); - const plan = driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }); - const { inputs } = gateway.dispatches[0]; - assert.deepEqual(new Set(Object.keys(inputs)), new Set(planner.CANDIDATE_DISPATCH_INPUTS)); - assert.equal(inputs.assets_immutable_releases_enabled, 'true'); - assert.equal(plan.dispatchRef, DEFAULT_BRANCH); -})); - -test('test_duplicate_candidate_artifacts_fail_closed', withAdvancePipelineFixture((self) => { - const candidateDir = path.join(self.tmp, 'candidate-src'); - writeBridgeCandidate(candidateDir, { - releaseTag: 'v0.1.40', releaseRebuild: 0, correlationId: self.correlationId, runId: CANDIDATE_RUN_ID, - }); - const members = directoryMembers(candidateDir); - const succeeded = runPayload({ runId: CANDIDATE_RUN_ID, path: model.CANDIDATE_WORKFLOW_PATH, runName: self.candidateName }); - const routes = succeededCandidateRoutes(self, self.routes({ candidateRuns: [succeeded] }), succeeded, [{ - id: 8, - name: CANDIDATE_ARTIFACT_NAME, - expired: false, - workflow_run: { id: pyIntFromString(CANDIDATE_RUN_ID) }, - }]); - const gateway = new FakeGateway({ - jsonRoutes: routes, - blobRoutes: { [`repos/${BRIDGE_REPOSITORY}/actions/artifacts/${CANDIDATE_ARTIFACT_ID}/zip`]: flatZip(members) }, - }); - assert.throws(() => driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }), ContractError); -})); - -// _assert_candidate_manifest_binding_mismatch. -function assertCandidateManifestBindingMismatch(self, candidateOverrides) { - const candidateDir = path.join(self.tmp, 'candidate-src'); - writeBridgeCandidate(candidateDir, { - releaseTag: 'v0.1.40', - releaseRebuild: 0, - correlationId: self.correlationId, - runId: CANDIDATE_RUN_ID, - ...candidateOverrides, - }); - const members = directoryMembers(candidateDir); - const succeeded = runPayload({ runId: CANDIDATE_RUN_ID, path: model.CANDIDATE_WORKFLOW_PATH, runName: self.candidateName }); - const routes = succeededCandidateRoutes(self, self.routes({ candidateRuns: [succeeded] }), succeeded); - const gateway = new FakeGateway({ - jsonRoutes: routes, - blobRoutes: { [`repos/${BRIDGE_REPOSITORY}/actions/artifacts/${CANDIDATE_ARTIFACT_ID}/zip`]: flatZip(members) }, - }); - assert.throws(() => driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }), ContractError); -} - -test('test_candidate_manifest_source_that_contradicts_run_name_fails_closed', withAdvancePipelineFixture((self) => { - assertCandidateManifestBindingMismatch(self, { bridgeCommit: ADVANCED_BRIDGE_SHA }); -})); - -test('test_candidate_manifest_tag_that_contradicts_run_name_fails_closed', withAdvancePipelineFixture((self) => { - assertCandidateManifestBindingMismatch(self, { releaseTag: 'v0.1.41' }); -})); - -test('test_post_dispatch_readback_absence_fails_closed', withAdvancePipelineFixture((self) => { - const gateway = new FakeGateway({ jsonRoutes: self.routes({ releases: [assetReleaseStub()] }) }); - assert.throws(() => driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }), ContractError); - assert.equal(gateway.dispatches.length, 1); -})); - -test('test_advance_pipeline_fails_closed_on_malformed_correlated_candidate_run', withAdvancePipelineFixture((self) => { - const malformedRun = runPayload({ - runId: '501', - path: model.CANDIDATE_WORKFLOW_PATH, - runName: `bridge-candidate ${self.correlationId} source:not-a-sha tag:v0.1.40 rebuild:0`, - status: 'in_progress', - conclusion: null, - }); - const gateway = new FakeGateway({ jsonRoutes: self.routes({ candidateRuns: [malformedRun] }) }); - assert.throws(() => driver.advancePipeline(gateway, { provenance: self.provenance, workspace: self.tmp }), ContractError); - assert.deepEqual(gateway.dispatches, []); -})); diff --git a/tests/release/orchestrator/fixtures.mjs b/tests/release/orchestrator/fixtures.mjs deleted file mode 100644 index a582d34..0000000 --- a/tests/release/orchestrator/fixtures.mjs +++ /dev/null @@ -1,531 +0,0 @@ -// Shared fixtures for the stable release orchestrator suites, the port of -// scripts/release_orchestrator_fixtures_test.py. -// -// Pinned identities, provenance and payload builders, the fake Gateway, and the -// AdvancePipelineFixture setup the driver suites share. It defines no tests of -// its own. Every name keeps its Python meaning in camelCase: a keyword -// argument is an options key, a builder that returns API or manifest JSON keeps -// the JSON's snake_case keys, and a dataclass builder takes the model's -// camelCase options. - -import { createHash } from 'node:crypto'; -import fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import process from 'node:process'; - -import { ASSETS_REPOSITORY, BRIDGE_REPOSITORY, ContractError, NATIVE_REPOSITORY } from '../../../scripts/release/contract.mjs'; -import { - compareCodePoints, pyIntFromString, pyJsonDumps, pyJsonLoads, pyRepr, -} from '../../../scripts/release/json.mjs'; -import { ARTIFACTS, generate } from '../../../scripts/release/manifest.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as runNames from '../../../scripts/release/orchestrator/run_names.mjs'; -import * as workflowRuns from '../../../scripts/release/orchestrator/workflow_runs.mjs'; -import { PUBLICATION_FILES } from '../../../scripts/release/publication_state.mjs'; -import { artifactBytes } from '../core_wasm_fixture.mjs'; -import { ZIP_DEFLATED, buildZip } from '../zip_fixture.mjs'; - -export const BRIDGE_SHA = '565c8396597ea7c0fb4e8d5d966da8d884b156d8'; -export const ADVANCED_BRIDGE_SHA = '9'.repeat(40); -export const UPSTREAM_COMMIT = 'bb4caa7540188872173c44d161602d9271386413'; -export const NATIVE_COMMIT = '1'.repeat(40); -export const NATIVE_MANIFEST_SHA = '2e5d29d7f98f0d71e75d3fa63b7c55f3b2a7933247cc34ea2b1c5e053d142452'; -export const CANDIDATE_RUN_ID = '32919086955'; -export const QUALIFICATION_RUN_ID = '32919086977'; -export const CANDIDATE_ARTIFACT_ID = 7; -export const QUALIFICATION_ARTIFACT_ID = 9; -export const DEFAULT_BRANCH = 'main'; -export const HEAD_SHA = 'a'.repeat(40); -export const ASSETS_TAG_COMMIT = 'c'.repeat(40); -export const OWNER = BRIDGE_REPOSITORY.split('/')[0]; -export const EMSCRIPTEN_VERSION = '6.0.8'; -export const NATIVE_PUBLISHED_AT = '2026-08-19T12:34:56Z'; -export const LEGACY_BRIDGE_SHA = '0bdc8286fd52b70da27f5b039e1b4278361da0be'; -export const LEGACY_UPSTREAM_COMMIT = 'c1d0e7a004015f23bc0233470b747b596f29b264'; -export const LEGACY_NATIVE_COMMIT = '28fca14873d4b4c531bef4425b261e2b911bdcce'; -export const LEGACY_NATIVE_MANIFEST_SHA = '811fda999e70c3ad2716d1c196688dd38db62cf11a78044855ca94f71fabed45'; -export const LEGACY_CANDIDATE_RUN_ID = '33225744070'; -export const LEGACY_MANUAL_QUALIFICATION_GATES = Object.freeze({ - state_persistence: 'passed', - multimodal: 'passed', - speech_to_text: 'required-local-attestation', - text_to_speech: 'required-local-attestation', -}); -export const LEGACY_MANUAL_UNPROVEN_CAPABILITIES = Object.freeze({ - real_device_intelligibility: 'unproven', - real_device_playback: 'unproven', - speaker_reference_fidelity: 'unproven', -}); - -// make_provenance(**overrides), with camelCase override keys. -export function makeProvenance(overrides = {}) { - return new model.NativeProvenance({ - bridgeSourceSha: BRIDGE_SHA, - bridgeBuildSha: BRIDGE_SHA, - upstreamTag: 'v0.2.0', - upstreamCommit: UPSTREAM_COMMIT, - nativeRepo: NATIVE_REPOSITORY, - nativeReleaseTag: 'v0.2.0', - nativeCommit: NATIVE_COMMIT, - nativeManifestSha256: NATIVE_MANIFEST_SHA, - nativeReleasePublishedAt: NATIVE_PUBLISHED_AT, - ...overrides, - }); -} - -export function makeLegacyV0140Provenance(overrides = {}) { - return new model.NativeProvenance({ - bridgeSourceSha: LEGACY_BRIDGE_SHA, - bridgeBuildSha: LEGACY_BRIDGE_SHA, - upstreamTag: 'v0.3.0', - upstreamCommit: LEGACY_UPSTREAM_COMMIT, - nativeRepo: NATIVE_REPOSITORY, - nativeReleaseTag: 'v0.3.0', - nativeCommit: LEGACY_NATIVE_COMMIT, - nativeManifestSha256: LEGACY_NATIVE_MANIFEST_SHA, - nativeReleasePublishedAt: '2026-08-28T12:34:56Z', - ...overrides, - }); -} - -// native_manifest(**overrides): the assets.json dict (snake_case JSON keys). -export function nativeManifest(overrides = {}) { - return { - schema_version: 1, - native_release_tag: 'v0.2.0', - llama_cpp_tag: 'v0.2.0', - llama_cpp_commit: UPSTREAM_COMMIT, - native_commit: NATIVE_COMMIT, - ...overrides, - }; -} - -// write_bridge_candidate(directory, ...): the artifacts (marker bytes plus -// "--") and generate()'s manifest.json and sha256sums.txt. -export function writeBridgeCandidate(directory, { - releaseTag, - releaseRebuild, - correlationId, - bridgeCommit = BRIDGE_SHA, - runId = CANDIDATE_RUN_ID, - marker = Buffer.from('candidate'), - upstreamTag = 'v0.2.0', - upstreamCommit = UPSTREAM_COMMIT, - nativeReleaseTag = 'v0.2.0', - nativeManifestSha256 = NATIVE_MANIFEST_SHA, - nativeCommit = NATIVE_COMMIT, -}) { - fs.mkdirSync(directory, { recursive: true }); - ARTIFACTS.forEach((name, index) => { - fs.writeFileSync(path.join(directory, name), artifactBytes(name, Buffer.concat([Buffer.from(marker), Buffer.from(`-${index}-${name}`, 'utf8')]))); - }); - generate({ - outDir: directory, - releaseTag, - releaseRebuild, - assetsRepo: ASSETS_REPOSITORY, - bridgeRepo: BRIDGE_REPOSITORY, - bridgeCommit, - upstreamRepo: 'ggml-org/llama.cpp', - upstreamTag, - upstreamCommit, - nativeRepo: NATIVE_REPOSITORY, - nativeReleaseTag, - nativeManifestSha256, - nativeCommit, - emscriptenVersion: EMSCRIPTEN_VERSION, - orchestratorCorrelationId: correlationId, - githubRunId: runId, - githubRunUrl: `https://github.com/${BRIDGE_REPOSITORY}/actions/runs/${runId}`, - }); -} - -// Rewrite manifest.json with the legacy v0.1.40 gate vocabulary, as -// json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n". -export function rewriteLegacyCandidateManifest(directory) { - const manifestPath = path.join(directory, 'manifest.json'); - const manifest = pyJsonLoads(fs.readFileSync(manifestPath, 'utf8')); - manifest.qualification_gates = { ...LEGACY_MANUAL_QUALIFICATION_GATES }; - manifest.unproven_capabilities = { ...LEGACY_MANUAL_UNPROVEN_CAPABILITIES }; - fs.writeFileSync(manifestPath, `${pyJsonDumps(manifest, { sortKeys: true, separators: [',', ':'] })}\n`, 'utf8'); -} - -// flat_zip(members): a deflated archive of { name: bytes }, members in sorted -// name order. -export function flatZip(members) { - const entries = Object.entries(members).sort(([left], [right]) => compareCodePoints(left, right)); - return buildZip(entries.map(([name, data]) => [name, Buffer.from(data)]), { compression: ZIP_DEFLATED }); -} - -// directory_members(directory): { name: bytes } for every PUBLICATION_FILES -// member, in that order. -export function directoryMembers(directory) { - return Object.fromEntries(PUBLICATION_FILES.map((name) => [name, fs.readFileSync(path.join(directory, name))])); -} - -function sha256Hex(data) { - return createHash('sha256').update(data).digest('hex'); -} - -// release_payload(...): an assets-repository release object with one uploaded -// asset per member (sorted by name, ids from 900), each digest the member's -// SHA-256. assetOverrides maps a member name to fields merged into its asset. -export function releasePayload({ - tag, - body, - members, - releaseId = 4242, - immutable = true, - publishedAt = '2026-08-20T03:24:11Z', - draft = false, - prerelease = null, - targetCommitish = ASSETS_TAG_COMMIT, - assetOverrides = null, -}) { - const assets = Object.entries(members) - .sort(([left], [right]) => compareCodePoints(left, right)) - .map(([name, data], index) => { - const asset = { - id: 900 + index, - name, - state: 'uploaded', - size: data.length, - digest: `sha256:${sha256Hex(data)}`, - }; - if (assetOverrides && Object.hasOwn(assetOverrides, name)) Object.assign(asset, assetOverrides[name]); - return asset; - }); - return { - id: releaseId, - tag_name: tag, - name: tag, - draft, - prerelease: prerelease === null ? tag.includes('-') : prerelease, - immutable, - target_commitish: targetCommitish, - published_at: publishedAt, - body, - assets, - }; -} - -export function assetReleaseStub(tag = 'v0.1.39') { - return { - tag_name: tag, - draft: false, - prerelease: tag.includes('-'), - published_at: '2026-08-20T03:24:11Z', - body: '', - }; -} - -// A listed publication carrying the deterministic release-note markers. -export function alignedReleaseStub(tag, provenance) { - const release = assetReleaseStub(tag); - release.body = ( - `Native: \`${provenance.nativeRepo}@${provenance.nativeReleaseTag}\`\n` - + `Native manifest SHA-256: \`${provenance.nativeManifestSha256}\`\n` - + `Orchestrator correlation: \`${runNames.computeCorrelationId(provenance)}\`\n` - ); - return release; -} - -// run_payload(...): one actions/runs record. `conclusion` defaults to -// "success"; pass null for None. It is always null for a run not completed. -export function runPayload({ - runId, - path: workflowPath, - runName, - status = 'completed', - conclusion = 'success', - headBranch = DEFAULT_BRANCH, - headSha = HEAD_SHA, - runAttempt = 1, - event = 'workflow_dispatch', - apiName = null, - actor = OWNER, - triggeringActor = OWNER, -}) { - return { - id: pyIntFromString(runId), - // Workflows with run-name expose the rendered correlation string in both - // fields. Keep the fixture aligned with the live Actions API. - name: apiName === null ? runName : apiName, - display_title: runName, - path: workflowPath, - event, - status, - conclusion: status === 'completed' ? conclusion : null, - head_branch: headBranch, - head_sha: headSha, - run_attempt: runAttempt, - repository: { full_name: BRIDGE_REPOSITORY }, - head_repository: { full_name: BRIDGE_REPOSITORY }, - actor: { login: actor }, - triggering_actor: { login: triggeringActor }, - }; -} - -// The live API answers an object holding workflow_runs, never a bare list. -export function runsResponse(runs) { - return { total_count: runs.length, workflow_runs: runs }; -} - -export function artifactInventory({ - runId, name, artifactId, extra = null, -}) { - const artifacts = [{ - id: artifactId, - name, - expired: false, - workflow_run: { id: pyIntFromString(runId) }, - }]; - artifacts.push(...(extra ?? [])); - return { total_count: artifacts.length, artifacts }; -} - -// The key of FakeGateway's releaseAttestations: the (repository, release_tag) -// tuple. -export function attestationKey(repository, releaseTag) { - return JSON.stringify([repository, releaseTag]); -} - -// Deterministic stand-in for the live gh transport (GhGateway's interface). -// -// jsonRoutes and blobRoutes map an exact API path to its answer; they are -// plain objects the tests may extend after construction. releaseAttestations -// is an iterable of [[repository, releaseTag], payload] entries. The gateway -// records every apiJson path in apiPaths, every dispatch as -// { workflowFile, ref, inputs } in dispatches, and every sleep in slept; -// utcNow() is the fixed `now`. `identity` and `governance` take null for -// Python's None; an omitted identity is the owner. -export class FakeGateway { - constructor({ - jsonRoutes = null, - blobRoutes = null, - identity = OWNER, - governance = null, - releaseAttestations = null, - now = '2026-08-30T00:00:00Z', - } = {}) { - this.jsonRoutes = { ...(jsonRoutes ?? {}) }; - this.blobRoutes = { ...(blobRoutes ?? {}) }; - this.identity = identity; - this.releaseAttestations = new Map(); - for (const [[repository, releaseTag], payload] of releaseAttestations ?? []) { - this.releaseAttestations.set(attestationKey(repository, releaseTag), payload); - } - this.now = now; - this.dispatches = []; - this.slept = []; - this.apiPaths = []; - const setdefault = (key, value) => { - if (!Object.hasOwn(this.jsonRoutes, key)) this.jsonRoutes[key] = value; - }; - setdefault(`repos/${BRIDGE_REPOSITORY}`, { default_branch: DEFAULT_BRANCH }); - setdefault(`repos/${BRIDGE_REPOSITORY}/environments/bridge-assets-publication`, { - name: 'bridge-assets-publication', - can_admins_bypass: false, - protection_rules: [{ type: 'branch_policy' }], - deployment_branch_policy: { - protected_branches: false, - custom_branch_policies: true, - }, - }); - setdefault(`repos/${BRIDGE_REPOSITORY}/environments/bridge-assets-publication/deployment-branch-policies`, { - total_count: 1, - branch_policies: [{ name: 'main', type: 'branch' }], - }); - setdefault( - `repos/${ASSETS_REPOSITORY}/immutable-releases`, - governance !== null ? governance : { enabled: true, enforced_by_owner: true }, - ); - setdefault(`repos/${ASSETS_REPOSITORY}/git/matching-refs/tags?per_page=100`, [[]]); - } - - apiJson(apiPath, { paginate: _paginate = false, privileged: _privileged = false } = {}) { - this.apiPaths.push(apiPath); - if (!Object.hasOwn(this.jsonRoutes, apiPath)) { - const recent = this.recentRuns(apiPath); - if (recent !== null) return recent; - throw new ContractError(`unmapped API path in test gateway: ${apiPath}`); - } - return this.jsonRoutes[apiPath]; - } - - // Node-only: the dispatch guard's unfiltered newest page of a workflow's - // runs (workflowRuns.recentWorkflowRunsPath), when a test does not map it, - // answers as a consistent server would: every run the test's filtered - // listings of that workflow hold, newest (highest id) first. A test that - // needs the two query shapes to disagree maps the path explicitly. Null - // for any other path, or a workflow with no filtered listing. - recentRuns(apiPath) { - const match = /^repos\/[^/]+\/[^/]+\/actions\/workflows\/([^/?]+)\/runs\?per_page=100$/u.exec(apiPath); - if (match === null || apiPath !== workflowRuns.recentWorkflowRunsPath(match[1])) return null; - const prefix = `repos/${BRIDGE_REPOSITORY}/actions/workflows/${match[1]}/runs?`; - const runs = new Map(); - let listed = false; - for (const [key, value] of Object.entries(this.jsonRoutes)) { - if (!key.startsWith(prefix) || !Array.isArray(value?.workflow_runs)) continue; - listed = true; - for (const run of value.workflow_runs) { - if (!runs.has(run.id)) runs.set(run.id, run); - } - } - if (!listed) return null; - const newest = [...runs.values()].sort((left, right) => Number(BigInt(right.id) - BigInt(left.id))).slice(0, 100); - return { total_count: runs.size, workflow_runs: newest }; - } - - downloadBytes(apiPath, { accept: _accept, privileged: _privileged = false } = {}) { - if (!Object.hasOwn(this.blobRoutes, apiPath)) throw new ContractError(`unmapped blob path in test gateway: ${apiPath}`); - return this.blobRoutes[apiPath]; - } - - dispatchIdentity() { - return this.identity; - } - - releaseAttestation({ repository, releaseTag }) { - const key = attestationKey(repository, releaseTag); - if (!this.releaseAttestations.has(key)) { - throw new ContractError(`unmapped release attestation in test gateway: (${pyRepr(repository)}, ${pyRepr(releaseTag)})`); - } - return this.releaseAttestations.get(key); - } - - dispatchWorkflow({ workflowFile, ref, inputs }) { - this.dispatches.push({ workflowFile, ref, inputs: { ...inputs } }); - } - - sleep(seconds) { - this.slept.push(seconds); - } - - utcNow() { - return this.now; - } -} - -// _provenance_to_dict of stable_release_orchestrator.py. -export function provenanceToDict(provenance) { - return { - bridge_source_sha: provenance.bridgeSourceSha, - bridge_build_sha: provenance.bridgeBuildSha, - upstream_tag: provenance.upstreamTag, - upstream_commit: provenance.upstreamCommit, - native_repo: provenance.nativeRepo, - native_release_tag: provenance.nativeReleaseTag, - native_commit: provenance.nativeCommit, - native_manifest_sha256: provenance.nativeManifestSha256, - native_release_published_at: provenance.nativeReleasePublishedAt, - }; -} - -// Setup and routes shared by every AdvancePipelineTest suite: the Python -// mixin's setUp state (tmp, provenance, correlationId, binding, candidateName) -// and helpers (routes, newerNative, runBacklog). withAdvancePipelineFixture(fn) -// wraps one test on a fresh fixture and always removes its directory. -// -// runBacklog drives the CLI entry's `orchestrate-backlog` with the fake -// gateway, which Python does by patching stable_release_orchestrator.GhGateway. -// The entry module is injected as `orchestrator` (constructor option or -// runBacklog option) and must export -// main(argv, { env, createGateway, stdout, stderr }) -> exit status (or a -// Promise of it) -// where createGateway({ readToken, dispatchToken }) replaces the GhGateway -// constructor, env replaces process.env, and stdout/stderr receive the -// output text. If it exports provenanceToDict, runBacklog uses it. -export class AdvancePipelineFixture { - constructor({ orchestrator = null } = {}) { - this.orchestrator = orchestrator; - this.tmp = fs.mkdtempSync(path.join(tmpdir(), 'sro-advance-')); - this.provenance = makeProvenance(); - this.correlationId = runNames.computeCorrelationId(this.provenance); - this.binding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.40', releaseRebuild: 0 }); - this.candidateName = runNames.candidateRunName(this.correlationId, this.binding); - } - - tearDown() { - fs.rmSync(this.tmp, { recursive: true, force: true }); - } - - // _routes(...): the paginated releases listing, both compare answers, and the - // three filtered run listings since NATIVE_PUBLISHED_AT. - routes({ - releases = null, candidateRuns = null, qualificationRuns = null, publishRuns = null, - } = {}) { - const runsPath = (workflowFile) => workflowRuns.workflowRunsPath({ - workflowFile, - defaultBranch: DEFAULT_BRANCH, - createdSince: NATIVE_PUBLISHED_AT, - }); - return { - [`repos/${ASSETS_REPOSITORY}/releases?per_page=100`]: [releases || []], - [`repos/${BRIDGE_REPOSITORY}/compare/${BRIDGE_SHA}...${DEFAULT_BRANCH}`]: { status: 'ahead' }, - [`repos/${BRIDGE_REPOSITORY}/compare/${HEAD_SHA}...${DEFAULT_BRANCH}`]: { status: 'identical' }, - [runsPath(model.CANDIDATE_WORKFLOW_FILE)]: runsResponse(candidateRuns || []), - [runsPath(model.QUALIFICATION_WORKFLOW_FILE)]: runsResponse(qualificationRuns || []), - [runsPath(model.PUBLISH_WORKFLOW_FILE)]: runsResponse(publishRuns || []), - }; - } - - // _newer_native(**overrides): a v0.2.1 provenance. - newerNative(overrides = {}) { - return makeProvenance({ - upstreamTag: 'v0.2.1', - upstreamCommit: 'd'.repeat(40), - nativeReleaseTag: 'v0.2.1', - nativeCommit: 'e'.repeat(40), - nativeManifestSha256: 'f'.repeat(64), - nativeReleasePublishedAt: '2026-08-21T12:34:56Z', - ...overrides, - }); - } - - // _run_backlog(gateway, provenances): [exit status, parsed plan JSON]. - async runBacklog(gateway, provenances, { orchestrator = this.orchestrator } = {}) { - if (!orchestrator) { - throw new Error('AdvancePipelineFixture.runBacklog needs the orchestrator CLI entry module (stage 2)'); - } - const toDict = orchestrator.provenanceToDict ?? provenanceToDict; - const provenanceList = path.join(this.tmp, 'release-candidates.json'); - fs.writeFileSync(provenanceList, pyJsonDumps(provenances.map((value) => toDict(value))), 'utf8'); - const outputPlan = path.join(this.tmp, 'orchestration-plan.json'); - const env = { - ...process.env, - GITHUB_EVENT_NAME: 'schedule', - GITHUB_ACTOR: 'github-actions', - GITHUB_TRIGGERING_ACTOR: 'github-actions', - }; - const result = await orchestrator.main([ - 'orchestrate-backlog', - '--provenance-list-json', - provenanceList, - '--workspace', - path.join(this.tmp, 'workspace'), - '--output-plan-json', - outputPlan, - ], { - env, - createGateway: () => gateway, - stdout: () => {}, - stderr: () => {}, - }); - return [result, pyJsonLoads(fs.readFileSync(outputPlan, 'utf8'))]; - } -} - -// One AdvancePipelineTest test: fn(fixture) on a fresh fixture (options go to -// the constructor), which is always torn down. -export function withAdvancePipelineFixture(fn, options = {}) { - return async () => { - const fixture = new AdvancePipelineFixture(options); - try { - await fn(fixture); - } finally { - fixture.tearDown(); - } - }; -} diff --git a/tests/release/orchestrator/fixtures_self_test.mjs b/tests/release/orchestrator/fixtures_self_test.mjs deleted file mode 100644 index 0c1ffb1..0000000 --- a/tests/release/orchestrator/fixtures_self_test.mjs +++ /dev/null @@ -1,141 +0,0 @@ -// Node-only checks that tests/release/orchestrator/fixtures.mjs builds what -// scripts/release_orchestrator_fixtures_test.py builds, so the stage-2 driver -// suites can rely on it: the fake gateway's routes, recording and error text, -// candidate directories the release validators accept, flat zips the artifact -// extractor accepts, and the release and run payload shapes. - -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { test } from 'node:test'; - -import { extractFlatArtifactArchive } from '../../../scripts/release/archive.mjs'; -import { ASSETS_REPOSITORY, BRIDGE_REPOSITORY, ContractError } from '../../../scripts/release/contract.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as runNames from '../../../scripts/release/orchestrator/run_names.mjs'; -import { loadCandidate, loadPublishedCandidate } from '../../../scripts/release/qualification.mjs'; -import { - ASSETS_TAG_COMMIT, CANDIDATE_RUN_ID, DEFAULT_BRANCH, FakeGateway, HEAD_SHA, - LEGACY_MANUAL_QUALIFICATION_GATES, LEGACY_MANUAL_UNPROVEN_CAPABILITIES, NATIVE_PUBLISHED_AT, OWNER, - alignedReleaseStub, artifactInventory, directoryMembers, flatZip, makeProvenance, releasePayload, - rewriteLegacyCandidateManifest, runPayload, runsResponse, withAdvancePipelineFixture, writeBridgeCandidate, -} from './fixtures.mjs'; - -test('fake gateway defaults, recording and error text', () => { - const gateway = new FakeGateway({ jsonRoutes: { 'repos/x': { a: 1 } }, releaseAttestations: [[[ASSETS_REPOSITORY, 'v0.1.40'], { ok: true }]] }); - assert.deepEqual(gateway.apiJson(`repos/${BRIDGE_REPOSITORY}`), { default_branch: DEFAULT_BRANCH }); - assert.deepEqual(gateway.apiJson(`repos/${ASSETS_REPOSITORY}/immutable-releases`), { enabled: true, enforced_by_owner: true }); - assert.deepEqual(gateway.apiJson(`repos/${ASSETS_REPOSITORY}/git/matching-refs/tags?per_page=100`, { paginate: true }), [[]]); - assert.deepEqual(gateway.apiJson('repos/x'), { a: 1 }); - assert.throws(() => gateway.apiJson('repos/y'), new ContractError('unmapped API path in test gateway: repos/y')); - assert.deepEqual(gateway.apiPaths, [ - `repos/${BRIDGE_REPOSITORY}`, - `repos/${ASSETS_REPOSITORY}/immutable-releases`, - `repos/${ASSETS_REPOSITORY}/git/matching-refs/tags?per_page=100`, - 'repos/x', - 'repos/y', - ]); - assert.throws(() => gateway.downloadBytes('blob', { accept: 'application/zip' }), new ContractError('unmapped blob path in test gateway: blob')); - assert.deepEqual(gateway.releaseAttestation({ repository: ASSETS_REPOSITORY, releaseTag: 'v0.1.40' }), { ok: true }); - assert.throws( - () => gateway.releaseAttestation({ repository: ASSETS_REPOSITORY, releaseTag: 'v0.1.41' }), - new ContractError(`unmapped release attestation in test gateway: ('${ASSETS_REPOSITORY}', 'v0.1.41')`), - ); - gateway.dispatchWorkflow({ workflowFile: 'a.yml', ref: 'main', inputs: { x: '1' } }); - gateway.sleep(5); - assert.deepEqual(gateway.dispatches, [{ workflowFile: 'a.yml', ref: 'main', inputs: { x: '1' } }]); - assert.deepEqual(gateway.slept, [5]); - assert.equal(gateway.utcNow(), '2026-08-30T00:00:00Z'); - assert.equal(gateway.dispatchIdentity(), OWNER); - assert.equal(new FakeGateway({ identity: null }).dispatchIdentity(), null); - assert.deepEqual(new FakeGateway({ governance: { enabled: false } }).apiJson(`repos/${ASSETS_REPOSITORY}/immutable-releases`), { enabled: false }); -}); - -test('candidate, zip and release builders produce valid release inputs', () => { - const root = fs.mkdtempSync(path.join(tmpdir(), 'sro-fixtures-')); - try { - const provenance = makeProvenance(); - const correlationId = runNames.computeCorrelationId(provenance); - const candidate = path.join(root, 'candidate'); - writeBridgeCandidate(candidate, { releaseTag: 'v0.1.40', releaseRebuild: 0, correlationId }); - const [manifest] = loadCandidate(candidate); - assert.equal(manifest.orchestrator_correlation_id, correlationId); - assert.equal(manifest.github_run_id, CANDIDATE_RUN_ID); - - const members = directoryMembers(candidate); - const archive = path.join(root, 'candidate.zip'); - fs.writeFileSync(archive, flatZip(members)); - const extracted = path.join(root, 'extracted'); - extractFlatArtifactArchive(archive, extracted, { artifactType: 'candidate' }); - for (const [name, data] of Object.entries(members)) assert.deepEqual(fs.readFileSync(path.join(extracted, name)), data, name); - - const release = releasePayload({ tag: 'v0.1.40', body: 'b', members }); - assert.equal(release.prerelease, false); - assert.equal(release.target_commitish, ASSETS_TAG_COMMIT); - assert.deepEqual(release.assets.map((asset) => asset.name), Object.keys(members).sort()); - assert.equal(release.assets[0].id, 900); - assert.match(release.assets[0].digest, /^sha256:[0-9a-f]{64}$/u); - assert.equal(releasePayload({ tag: 'v0.1.40-1', body: '', members: {} }).prerelease, true); - assert.ok(alignedReleaseStub('v0.1.40', provenance).body.includes(`Orchestrator correlation: \`${correlationId}\``)); - - rewriteLegacyCandidateManifest(candidate); - const text = fs.readFileSync(path.join(candidate, 'manifest.json'), 'utf8'); - assert.ok(text.startsWith('{"artifacts":') && text.endsWith('}\n') && !text.includes(': ')); - assert.deepEqual(JSON.parse(text).qualification_gates, LEGACY_MANUAL_QUALIFICATION_GATES); - assert.deepEqual(JSON.parse(text).unproven_capabilities, LEGACY_MANUAL_UNPROVEN_CAPABILITIES); - // The rewritten manifest satisfies the historical published contract. - const [published] = loadPublishedCandidate(candidate, { - expectedQualificationGates: LEGACY_MANUAL_QUALIFICATION_GATES, - expectedUnprovenCapabilities: LEGACY_MANUAL_UNPROVEN_CAPABILITIES, - }); - assert.deepEqual(published.qualification_gates, LEGACY_MANUAL_QUALIFICATION_GATES); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -test('run payloads and the advance fixture routes', withAdvancePipelineFixture((self) => { - const run = runPayload({ runId: '501', path: model.CANDIDATE_WORKFLOW_PATH, runName: self.candidateName, status: 'in_progress' }); - assert.equal(run.conclusion, null); - assert.equal(run.id, 501); - assert.equal(run.head_sha, HEAD_SHA); - assert.equal(runPayload({ runId: '502', path: 'p', runName: 'n', conclusion: null }).conclusion, null); - assert.equal(runPayload({ runId: '503', path: 'p', runName: 'n', apiName: 'Static' }).name, 'Static'); - assert.deepEqual(artifactInventory({ runId: '501', name: 'x', artifactId: 7 }), { - total_count: 1, - artifacts: [{ id: 7, name: 'x', expired: false, workflow_run: { id: 501 } }], - }); - const routes = self.routes({ candidateRuns: [run] }); - assert.deepEqual(routes[`repos/${ASSETS_REPOSITORY}/releases?per_page=100`], [[]]); - const candidateKey = Object.keys(routes).find((key) => key.includes('/bridge_candidate.yml/runs?')); - assert.equal( - candidateKey, - `repos/${BRIDGE_REPOSITORY}/actions/workflows/bridge_candidate.yml/runs?per_page=100&event=workflow_dispatch&branch=main&actor=${OWNER}&created=%3E%3D${NATIVE_PUBLISHED_AT.replaceAll(':', '%3A')}`, - ); - assert.deepEqual(routes[candidateKey], runsResponse([run])); - assert.equal(self.newerNative().nativeReleaseTag, 'v0.2.1'); - assert.ok(fs.statSync(self.tmp).isDirectory()); - assert.ok(self.binding.equals(new model.PipelineBinding({ bridgeSourceSha: self.provenance.bridgeSourceSha, releaseTag: 'v0.1.40', releaseRebuild: 0 }))); -})); - -test('runBacklog drives an injected CLI entry', async () => { - const calls = []; - const orchestrator = { - main(argv, { env, createGateway }) { - calls.push([argv[0], env.GITHUB_EVENT_NAME, createGateway({ readToken: 'r', dispatchToken: null })]); - const output = argv[argv.indexOf('--output-plan-json') + 1]; - const provenances = JSON.parse(fs.readFileSync(argv[argv.indexOf('--provenance-list-json') + 1], 'utf8')); - fs.writeFileSync(output, JSON.stringify({ plans: provenances.map((item) => item.native_release_tag) })); - return 0; - }, - }; - const gateway = new FakeGateway(); - await withAdvancePipelineFixture(async (self) => { - const [result, plan] = await self.runBacklog(gateway, [self.provenance, self.newerNative()]); - assert.equal(result, 0); - assert.deepEqual(plan, { plans: ['v0.2.0', 'v0.2.1'] }); - }, { orchestrator })(); - assert.deepEqual(calls, [['orchestrate-backlog', 'schedule', gateway]]); - await assert.rejects(withAdvancePipelineFixture((self) => self.runBacklog(gateway, []))); -}); diff --git a/tests/release/orchestrator/model_test.mjs b/tests/release/orchestrator/model_test.mjs deleted file mode 100644 index eff0d1a..0000000 --- a/tests/release/orchestrator/model_test.mjs +++ /dev/null @@ -1,116 +0,0 @@ -// Tests of scripts/release/orchestrator/model.mjs. The Python module has no -// suite of its own (its types are exercised through the other suites); these -// Node-only tests pin what the port adds: dataclass equality and set identity, -// the __init__ TypeError text, the validation messages, and to_dict(). - -import assert from 'node:assert/strict'; -import { test } from 'node:test'; - -import { ContractError } from '../../../scripts/release/contract.mjs'; -import { pyJsonDumps } from '../../../scripts/release/json.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as runNames from '../../../scripts/release/orchestrator/run_names.mjs'; -import { - BRIDGE_SHA, NATIVE_MANIFEST_SHA, makeLegacyV0140Provenance, makeProvenance, -} from './fixtures.mjs'; - -function message(fn) { - try { - fn(); - } catch (error) { - return `${error.pyType ?? error.name}: ${error.message}`; - } - return null; -} - -const binding = (overrides = {}) => new model.PipelineBinding({ - bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.40', releaseRebuild: 0, ...overrides, -}); - -test('dataclass equality and set identity follow Python', () => { - assert.ok(binding().equals(binding())); - assert.ok(!binding().equals(binding({ releaseTag: 'v0.1.41' }))); - assert.ok(!binding().equals(new model.ReleaseTarget({ releaseTag: 'v0.1.40', releaseRebuild: 0 }))); - assert.ok(binding().releaseTarget.equals(new model.ReleaseTarget({ releaseTag: 'v0.1.40', releaseRebuild: 0 }))); - const bindings = new model.PyValueSet([binding(), binding(), binding({ releaseTag: 'v0.1.41' })]); - assert.equal(bindings.size, 2); - assert.ok(bindings.has(binding())); - // 1 == 1.0 == True share one member, as they share one hash. - assert.equal(new model.PyValueSet([1, true]).size, 1); - assert.equal(message(() => model.pyHashKey({ a: 1 })), "TypeError: unhashable type: 'dict'"); - assert.ok(makeProvenance().equals(makeProvenance())); - assert.ok(Object.isFrozen(makeProvenance())); -}); - -test('dataclass __init__ raises Python TypeError text', () => { - assert.equal( - message(() => new model.ReleaseTarget({ releaseTag: 'v0.1.40' })), - "TypeError: ReleaseTarget.__init__() missing 1 required positional argument: 'release_rebuild'", - ); - assert.equal( - message(() => new model.PipelineBinding({})), - "TypeError: PipelineBinding.__init__() missing 3 required positional arguments: 'bridge_source_sha', 'release_tag', and 'release_rebuild'", - ); - assert.equal( - message(() => new model.PipelineObservation({ unknown: 1 })), - "TypeError: PipelineObservation.__init__() got an unexpected keyword argument 'unknown'", - ); - const observation = new model.PipelineObservation(); - assert.equal(observation.publishRetry, false); - assert.equal(observation.binding, null); -}); - -test('provenance and binding validation messages', () => { - assert.equal(message(() => makeProvenance({ bridgeBuildSha: 'A'.repeat(40) })), 'ContractError: bridge_build_sha must be a lowercase full 40-character commit SHA'); - assert.equal(message(() => makeProvenance({ nativeManifestSha256: 'x' })), 'ContractError: native_manifest_sha256 must be a lowercase 64-character SHA-256'); - assert.equal(message(() => makeProvenance({ nativeRepo: 'someone/llamadart-native' })), 'ContractError: native_repo must be exactly leehack/llamadart-native'); - assert.equal(message(() => makeProvenance({ nativeReleasePublishedAt: 'yesterday' })), 'ContractError: native_release_published_at must use YYYY-MM-DDTHH:MM:SSZ'); - assert.equal( - message(() => makeProvenance({ nativeReleaseTag: 'b9165' })), - "ContractError: native release 'b9165' and upstream tag 'v0.2.0' are on different channels", - ); - assert.equal(message(() => binding({ bridgeSourceSha: 'abc' })), 'ContractError: bridge_source_sha must be a lowercase full 40-character commit SHA'); - assert.equal(message(() => binding({ bridgeSourceSha: 1 })), "TypeError: expected string or bytes-like object, got 'int'"); - assert.equal(message(() => binding({ releaseRebuild: 1 })), "ContractError: release tag 'v0.1.40' encodes rebuild 0, not 1"); - assert.throws(() => model.requireStableProvenance(new model.NativeProvenance({ - ...makeProvenance(), upstreamTag: 'b1', nativeReleaseTag: 'b1', - })), new ContractError('the release orchestrator only advances the stable channel, but this provenance is development (b1@b1)')); - assert.equal(model.requireStr('x', 'label'), 'x'); - assert.throws(() => model.requireStr('', 'label'), new ContractError('label must be a non-empty string')); - assert.throws(() => model.requirePositiveInt(true, 'label'), new ContractError('label must be a positive integer')); - assert.equal(model.requirePositiveInt(2n ** 70n, 'label'), 2n ** 70n); -}); - -test('published manifest compatibility is the one legacy v0.1.40 identity', () => { - const legacy = makeLegacyV0140Provenance(); - assert.deepEqual(model.publishedManifestCompatibility({ tag: 'v0.1.40', provenance: legacy }), [ - model.LEGACY_MANUAL_QUALIFICATION_GATES, - model.LEGACY_MANUAL_UNPROVEN_CAPABILITIES, - ]); - assert.equal(model.publishedManifestCompatibility({ tag: 'v0.1.41', provenance: legacy }), null); - assert.equal(model.publishedManifestCompatibility({ tag: 'v0.1.40', provenance: makeProvenance() }), null); - assert.equal(runNames.computeCorrelationId(legacy), `auto-stable-v0.3.0-${legacy.nativeManifestSha256.slice(0, 16)}`); -}); - -test('plan to_dict keeps Python key order and values', () => { - const provenance = makeProvenance(); - const plan = new model.OrchestrationPlan({ - action: model.OrchestrationAction.DISPATCH_CANDIDATE, - reason: 'r', - provenance, - correlationId: 'c', - releaseTarget: new model.ReleaseTarget({ releaseTag: 'v0.1.40', releaseRebuild: 0 }), - dispatchInputs: { b: '1', a: '2' }, - }); - assert.equal( - pyJsonDumps(plan.toDict()), - '{"schema_version": 1, "action": "dispatch_candidate", "reason": "r", "correlation_id": "c", "provenance": ' - + `{"bridge_source_sha": "${BRIDGE_SHA}", "bridge_build_sha": "${BRIDGE_SHA}", "upstream_tag": "v0.2.0", ` - + '"upstream_commit": "bb4caa7540188872173c44d161602d9271386413", "native_repo": "leehack/llamadart-native", ' - + '"native_release_tag": "v0.2.0", "native_commit": "1111111111111111111111111111111111111111", ' - + `"native_manifest_sha256": "${NATIVE_MANIFEST_SHA}", "native_release_published_at": "2026-08-19T12:34:56Z"}, ` - + '"release_tag": "v0.1.40", "release_rebuild": 0, "candidate_run_id": null, "qualification_run_id": null, ' - + '"in_flight_workflow": null, "in_flight_run_id": null, "dispatch_workflow": null, "dispatch_ref": null, ' - + '"dispatch_run_name": null, "dispatch_inputs": {"b": "1", "a": "2"}, "dispatched_run_id": null}', - ); -}); diff --git a/tests/release/orchestrator/native_test.mjs b/tests/release/orchestrator/native_test.mjs deleted file mode 100644 index b670694..0000000 --- a/tests/release/orchestrator/native_test.mjs +++ /dev/null @@ -1,174 +0,0 @@ -// Tests of scripts/release/orchestrator/native.mjs, one test per test method of -// scripts/release_orchestrator_native_test.py, with the same names and -// assertions. Each scan writes into its own temporary directory. - -import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { test } from 'node:test'; - -import { Channel, ContractError, NATIVE_REPOSITORY } from '../../../scripts/release/contract.mjs'; -import { pyJsonDumps } from '../../../scripts/release/json.mjs'; -import * as orchestrator from '../../../scripts/release/orchestrator/cli.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as native from '../../../scripts/release/orchestrator/native.mjs'; -import { - BRIDGE_SHA, - NATIVE_COMMIT, - NATIVE_MANIFEST_SHA, - NATIVE_PUBLISHED_AT, - UPSTREAM_COMMIT, - makeProvenance, - nativeManifest, -} from './fixtures.mjs'; - -function withTemporaryDirectory(fn) { - const directory = fs.mkdtempSync(path.join(tmpdir(), 'sro-native-')); - try { - return fn(directory); - } finally { - fs.rmSync(directory, { recursive: true, force: true }); - } -} - -function scan(manifestPath, { nativeReleaseTag = 'v0.2.0', channel = 'stable' } = {}) { - return native.scanNativeProvenance({ - manifestPath, - nativeReleaseTag, - bridgeSourceSha: BRIDGE_SHA, - bridgeBuildSha: BRIDGE_SHA, - channel, - nativeReleasePublishedAt: NATIVE_PUBLISHED_AT, - }); -} - -// --- ProvenanceTest -------------------------------------------------------------- - -test('test_scan_native_binds_exact_manifest_bytes', () => { - const payload = Buffer.from(pyJsonDumps(nativeManifest()), 'utf8'); - const provenance = withTemporaryDirectory((directory) => { - const manifestPath = path.join(directory, 'assets.json'); - fs.writeFileSync(manifestPath, payload); - return scan(manifestPath); - }); - assert.equal(provenance.upstreamTag, 'v0.2.0'); - assert.equal(provenance.nativeReleaseTag, 'v0.2.0'); - assert.equal(provenance.nativeManifestSha256, createHash('sha256').update(payload).digest('hex')); -}); - -test('test_malformed_native_manifest_fails_closed', () => { - withTemporaryDirectory((directory) => { - const manifestPath = path.join(directory, 'assets.json'); - fs.writeFileSync(manifestPath, '{"native_release_tag": "v0.2.0", "native_release_tag": "v0.2.0"}'); - assert.throws(() => scan(manifestPath), ContractError); - }); -}); - -test('test_channel_inconsistent_provenance_is_rejected', () => { - assert.throws(() => makeProvenance({ upstreamTag: 'b9165' }), ContractError); - assert.throws(() => makeProvenance({ nativeReleaseTag: 'b9165' }), ContractError); -}); - -test('test_native_release_timestamp_is_canonical_and_bounded', () => { - assert.throws(() => makeProvenance({ nativeReleasePublishedAt: 'yesterday' }), ContractError); -}); - -// --- DevelopmentScanTest: manual development scans stay supported, and stay -// scan-only. ------------------------------------------------------------------------ - -function scanDevelopment(channel) { - return withTemporaryDirectory((directory) => { - const manifestPath = path.join(directory, 'assets.json'); - fs.writeFileSync(manifestPath, pyJsonDumps(nativeManifest({ native_release_tag: 'b9165', llama_cpp_tag: 'b9165' }))); - return scan(manifestPath, { nativeReleaseTag: 'b9165', channel }); - }); -} - -test('test_development_scan_still_prepares_exact_provenance', () => { - const provenance = scanDevelopment('development'); - assert.equal(provenance.upstreamTag, 'b9165'); - assert.equal(provenance.nativeReleaseTag, 'b9165'); - assert.equal(provenance.channel, Channel.DEVELOPMENT); -}); - -test('test_stable_scan_of_a_development_release_fails_closed', () => { - assert.throws(() => scanDevelopment('stable'), ContractError); -}); - -test('test_development_scan_of_a_stable_release_fails_closed', () => { - withTemporaryDirectory((directory) => { - const manifestPath = path.join(directory, 'assets.json'); - fs.writeFileSync(manifestPath, pyJsonDumps(nativeManifest())); - assert.throws(() => scan(manifestPath, { channel: 'development' }), ContractError); - }); -}); - -test('test_orchestration_refuses_a_development_provenance', () => { - const development = new model.NativeProvenance({ - bridgeSourceSha: BRIDGE_SHA, - bridgeBuildSha: BRIDGE_SHA, - upstreamTag: 'b9165', - upstreamCommit: UPSTREAM_COMMIT, - nativeRepo: NATIVE_REPOSITORY, - nativeReleaseTag: 'b9165', - nativeCommit: NATIVE_COMMIT, - nativeManifestSha256: NATIVE_MANIFEST_SHA, - nativeReleasePublishedAt: NATIVE_PUBLISHED_AT, - }); - assert.throws(() => model.requireStableProvenance(development), ContractError); - withTemporaryDirectory((directory) => { - const provenancePath = path.join(directory, 'provenance.json'); - fs.writeFileSync(provenancePath, pyJsonDumps({ - bridge_source_sha: BRIDGE_SHA, - bridge_build_sha: BRIDGE_SHA, - upstream_tag: 'b9165', - upstream_commit: UPSTREAM_COMMIT, - native_repo: NATIVE_REPOSITORY, - native_release_tag: 'b9165', - native_commit: NATIVE_COMMIT, - native_manifest_sha256: NATIVE_MANIFEST_SHA, - native_release_published_at: NATIVE_PUBLISHED_AT, - }), 'utf8'); - assert.throws(() => orchestrator.loadProvenance(provenancePath), ContractError); - }); -}); - -// --- StableNativeBacklogTest ------------------------------------------------------- - -function release(tag, publishedAt, { draft = false, prerelease = null } = {}) { - return { - tag_name: tag, - draft, - prerelease: prerelease === null ? tag.includes('-') : prerelease, - published_at: publishedAt, - }; -} - -test('test_selects_every_post_baseline_stable_release_in_publication_order', () => { - const releases = [ - release('v0.2.1', '2026-08-28T03:00:00Z'), - release('b10599', '2026-08-28T02:00:00Z', { prerelease: false }), - release('v0.2.0-1', native.STABLE_AUTOMATION_BASELINE_PUBLISHED_AT), - release('v0.2.0-2', '2026-08-27T03:00:00Z'), - release('v0.3.0', '2026-08-29T03:00:00Z', { draft: true }), - ]; - assert.deepEqual(native.selectStableNativeBacklog(releases), ['v0.2.0-2', 'v0.2.1']); -}); - -test('test_post_baseline_stable_rollback_fails_closed', () => { - assert.throws(() => native.selectStableNativeBacklog([release('v0.1.99', '2026-08-28T03:00:00Z')]), ContractError); -}); - -test('test_inconsistent_stable_prerelease_state_fails_closed', () => { - assert.throws( - () => native.selectStableNativeBacklog([release('v0.2.0-2', '2026-08-28T03:00:00Z', { prerelease: false })]), - ContractError, - ); -}); - -test('test_duplicate_stable_tag_fails_closed', () => { - const entry = release('v0.2.1', '2026-08-28T03:00:00Z'); - assert.throws(() => native.selectStableNativeBacklog([entry, { ...entry }]), ContractError); -}); diff --git a/tests/release/orchestrator/planner_test.mjs b/tests/release/orchestrator/planner_test.mjs deleted file mode 100644 index 51734e7..0000000 --- a/tests/release/orchestrator/planner_test.mjs +++ /dev/null @@ -1,295 +0,0 @@ -// Tests of scripts/release/orchestrator/planner.mjs, one test per test method -// of scripts/release_orchestrator_planner_test.py, with the same names and -// assertions. - -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { test } from 'node:test'; -import { fileURLToPath } from 'node:url'; - -import { ASSETS_REPOSITORY, ContractError } from '../../../scripts/release/contract.mjs'; -import { pySplitlines, pyStrip } from '../../../scripts/release/json.mjs'; -import * as orchestrator from '../../../scripts/release/orchestrator/cli.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as planner from '../../../scripts/release/orchestrator/planner.mjs'; -import * as runNames from '../../../scripts/release/orchestrator/run_names.mjs'; -import { - BRIDGE_SHA, - CANDIDATE_RUN_ID, - NATIVE_MANIFEST_SHA, - QUALIFICATION_RUN_ID, - UPSTREAM_COMMIT, - makeProvenance, -} from './fixtures.mjs'; - -const WORKFLOWS = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../.github/workflows'); - -// Read a workflow's exact workflow_dispatch input names, with the Python -// suite's dependency-free line scan. -function declaredWorkflowInputs(workflow) { - const names = []; - let inDispatch = false; - let inInputs = false; - for (const line of pySplitlines(fs.readFileSync(workflow, 'utf8'))) { - const stripped = pyStrip(line); - if (!stripped || stripped.startsWith('#')) continue; - if (!inDispatch) { - inDispatch = line === ' workflow_dispatch:'; - continue; - } - if (!inInputs) { - if (line === ' inputs:') { - inInputs = true; - continue; - } - if (!line.startsWith(' ')) break; - continue; - } - if (!line.startsWith(' ')) break; - if (line.startsWith(' ')) continue; - if (!stripped.endsWith(':')) throw new assert.AssertionError({ message: `unexpected input declaration: ${line}` }); - names.push(stripped.slice(0, -1)); - } - if (names.length === 0) throw new assert.AssertionError({ message: `${path.basename(workflow)} declares no workflow_dispatch inputs` }); - return names; -} - -const setOf = (values) => new Set(values); -const without = (values, ...removed) => new Set([...values].filter((value) => !removed.includes(value))); - -function setUp() { - const provenance = makeProvenance(); - return { - provenance, - correlationId: runNames.computeCorrelationId(provenance), - binding: new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.40', releaseRebuild: 0 }), - }; -} - -function plan(self, observation) { - return planner.planPipeline({ - provenance: self.provenance, - correlationId: self.correlationId, - observation: new model.PipelineObservation(observation), - }); -} - -// --- DispatchInputContractTest: a dispatch whose keys drift from the workflow -// is rejected at build time. --------------------------------------------------------- - -test('test_candidate_dispatch_inputs_are_exactly_the_declared_inputs', () => { - const self = setUp(); - const declared = declaredWorkflowInputs(path.join(WORKFLOWS, model.CANDIDATE_WORKFLOW_FILE)); - assert.deepEqual(setOf(declared), setOf(planner.CANDIDATE_DISPATCH_INPUTS)); - const inputs = planner.dispatchInputsForCandidate(self.provenance, self.correlationId, self.binding); - // The live governance proof supplies the immutability assertion, so the - // planner must not pre-declare it. - assert.deepEqual(setOf(Object.keys(inputs)), without(declared, 'assets_immutable_releases_enabled')); -}); - -test('test_publish_dispatch_inputs_are_exactly_the_declared_inputs', () => { - const self = setUp(); - const declared = declaredWorkflowInputs(path.join(WORKFLOWS, model.PUBLISH_WORKFLOW_FILE)); - assert.deepEqual(setOf(declared), setOf(planner.PUBLISH_DISPATCH_INPUTS)); - const inputs = planner.dispatchInputsForPublish(self.provenance, self.correlationId, self.binding, CANDIDATE_RUN_ID, QUALIFICATION_RUN_ID); - // The live environment-policy proof supplies this approval assertion. - assert.deepEqual(setOf(Object.keys(inputs)), without(declared, 'publish_approved')); -}); - -test('test_qualification_dispatch_inputs_are_exactly_the_declared_inputs', () => { - const self = setUp(); - const declared = declaredWorkflowInputs(path.join(WORKFLOWS, model.QUALIFICATION_WORKFLOW_FILE)); - assert.deepEqual(setOf(declared), setOf(planner.QUALIFICATION_DISPATCH_INPUTS)); - const inputs = planner.dispatchInputsForQualification(self.correlationId, CANDIDATE_RUN_ID); - assert.deepEqual(setOf(Object.keys(inputs)), setOf(declared)); -}); - -test('test_missing_dispatch_input_fails_closed', () => { - const self = setUp(); - const inputs = planner.dispatchInputsForPublish(self.provenance, self.correlationId, self.binding, CANDIDATE_RUN_ID, QUALIFICATION_RUN_ID); - assert.throws(() => planner.requireExactDispatchInputs(model.PUBLISH_WORKFLOW_FILE, inputs), ContractError); -}); - -test('test_unknown_dispatch_input_fails_closed', () => { - const self = setUp(); - const inputs = planner.dispatchInputsForCandidate(self.provenance, self.correlationId, self.binding); - inputs.assets_immutable_releases_enabled = 'true'; - inputs.unexpected = 'value'; - assert.throws(() => planner.requireExactDispatchInputs(model.CANDIDATE_WORKFLOW_FILE, inputs), ContractError); -}); - -test('test_undispatchable_workflow_fails_closed', () => { - assert.throws(() => planner.requireExactDispatchInputs('ci.yml', { candidate_run_id: CANDIDATE_RUN_ID }), ContractError); -}); - -test('test_non_string_dispatch_input_fails_closed', () => { - const self = setUp(); - const inputs = planner.dispatchInputsForCandidate(self.provenance, self.correlationId, self.binding); - inputs.assets_immutable_releases_enabled = 'true'; - inputs.release_rebuild = 0; - assert.throws(() => planner.requireExactDispatchInputs(model.CANDIDATE_WORKFLOW_FILE, inputs), ContractError); -}); - -// --- PlanTest: exhaustive pure-state-machine coverage over already-proven -// evidence. ------------------------------------------------------------------------- - -test('test_new_provenance_plans_exactly_one_candidate_dispatch', () => { - const self = setUp(); - const result = plan(self, { freshBinding: self.binding }); - assert.equal(result.action, model.OrchestrationAction.DISPATCH_CANDIDATE); - assert.equal(result.dispatchWorkflow, model.CANDIDATE_WORKFLOW_FILE); - const inputs = result.dispatchInputs ?? {}; - assert.equal(inputs.orchestrator_correlation_id, self.correlationId); - assert.equal(inputs.bridge_source_sha, BRIDGE_SHA); - assert.equal(inputs.upstream_tag, 'v0.2.0'); - assert.equal(inputs.upstream_commit, UPSTREAM_COMMIT); - assert.equal(inputs.native_release_tag, 'v0.2.0'); - assert.equal(inputs.native_manifest_sha256, NATIVE_MANIFEST_SHA); - assert.equal(inputs.release_tag, 'v0.1.40'); - assert.equal(inputs.release_rebuild, '0'); - // Everything the planner can know without a live read, and nothing else. - assert.deepEqual(setOf(Object.keys(inputs)), without(planner.CANDIDATE_DISPATCH_INPUTS, 'assets_immutable_releases_enabled')); -}); - -test('test_published_provenance_is_an_exact_noop', () => { - const self = setUp(); - const result = plan(self, { - published: new model.PublishedRelease({ - releaseId: 4242, - releaseTarget: new model.ReleaseTarget({ releaseTag: 'v0.1.40', releaseRebuild: 0 }), - binding: self.binding, - publishedAt: '2026-08-20T03:24:11Z', - }), - }); - assert.equal(result.action, model.OrchestrationAction.NOOP); - assert.equal(result.dispatchWorkflow, null); - assert.ok(result.reason.toLowerCase().includes('already published')); -}); - -test('test_in_flight_candidate_blocks_duplicate_dispatch', () => { - const self = setUp(); - const result = plan(self, { freshBinding: self.binding, candidateInFlightRunId: '501', binding: self.binding }); - assert.equal(result.action, model.OrchestrationAction.IN_FLIGHT); - assert.equal(result.inFlightWorkflow, model.CANDIDATE_WORKFLOW_FILE); - assert.equal(result.inFlightRunId, '501'); - assert.equal(result.dispatchWorkflow, null); -}); - -test('test_candidate_ready_plans_exactly_one_hosted_qualification', () => { - const self = setUp(); - const result = plan(self, { binding: self.binding, candidateRunId: CANDIDATE_RUN_ID }); - assert.equal(result.action, model.OrchestrationAction.DISPATCH_QUALIFICATION); - assert.equal(result.candidateRunId, CANDIDATE_RUN_ID); - assert.equal(result.dispatchWorkflow, model.QUALIFICATION_WORKFLOW_FILE); - assert.equal(result.dispatchRunName, runNames.qualificationRunName(self.correlationId, CANDIDATE_RUN_ID)); - assert.deepEqual(result.dispatchInputs, { - orchestrator_correlation_id: self.correlationId, - candidate_run_id: CANDIDATE_RUN_ID, - }); - assert.equal(result.qualificationRunId, null); - // No routine state may require a maintainer-supplied payload or an owner - // workflow_dispatch continuation to advance. - assert.ok(!result.reason.toLowerCase().includes('maintainer')); - assert.ok(!result.reason.toLowerCase().includes('attestation')); -}); - -test('test_in_flight_qualification_blocks_duplicate_publish', () => { - const self = setUp(); - const result = plan(self, { - binding: self.binding, - candidateRunId: CANDIDATE_RUN_ID, - qualificationInFlightRunId: QUALIFICATION_RUN_ID, - }); - assert.equal(result.action, model.OrchestrationAction.IN_FLIGHT); - assert.equal(result.inFlightWorkflow, model.QUALIFICATION_WORKFLOW_FILE); -}); - -test('test_candidate_and_qualification_ready_plan_publish', () => { - const self = setUp(); - const result = plan(self, { - binding: self.binding, - candidateRunId: CANDIDATE_RUN_ID, - qualificationRunId: QUALIFICATION_RUN_ID, - }); - assert.equal(result.action, model.OrchestrationAction.DISPATCH_PUBLISH); - assert.equal(result.dispatchWorkflow, model.PUBLISH_WORKFLOW_FILE); - const inputs = result.dispatchInputs ?? {}; - assert.equal(inputs.candidate_run_id, CANDIDATE_RUN_ID); - assert.equal(inputs.qualification_run_id, QUALIFICATION_RUN_ID); - assert.equal(inputs.release_tag, 'v0.1.40'); - assert.equal(inputs.release_rebuild, '0'); - assert.ok(!Object.hasOwn(inputs, 'publish_approved')); - assert.equal(inputs.assets_repo, ASSETS_REPOSITORY); - assert.equal(inputs.bridge_source_sha, BRIDGE_SHA); - assert.deepEqual(setOf(Object.keys(inputs)), without(planner.PUBLISH_DISPATCH_INPUTS, 'publish_approved')); -}); - -test('test_in_flight_publish_blocks_duplicate_publish', () => { - const self = setUp(); - const result = plan(self, { - binding: self.binding, - candidateRunId: CANDIDATE_RUN_ID, - qualificationRunId: QUALIFICATION_RUN_ID, - publishInFlightRunId: '701', - }); - assert.equal(result.action, model.OrchestrationAction.IN_FLIGHT); - assert.equal(result.inFlightWorkflow, model.PUBLISH_WORKFLOW_FILE); - assert.equal(result.inFlightRunId, '701'); -}); - -test('test_successful_publish_without_immutable_release_fails_closed', () => { - const self = setUp(); - assert.throws(() => plan(self, { - binding: self.binding, - candidateRunId: CANDIDATE_RUN_ID, - qualificationRunId: QUALIFICATION_RUN_ID, - publishSucceededRunId: '701', - }), ContractError); -}); - -test('test_publish_retry_after_failed_publish_reuses_exact_state', () => { - const self = setUp(); - const result = plan(self, { - binding: self.binding, - candidateRunId: CANDIDATE_RUN_ID, - qualificationRunId: QUALIFICATION_RUN_ID, - publishRetry: true, - }); - assert.equal(result.action, model.OrchestrationAction.DISPATCH_PUBLISH); - assert.equal(result.candidateRunId, CANDIDATE_RUN_ID); - assert.equal(result.qualificationRunId, QUALIFICATION_RUN_ID); - // A retry must be visible as a retry, not reported as a first attempt. - assert.ok(result.reason.includes('retry')); -}); - -test('test_planner_never_pre_asserts_immutable_release_governance', () => { - const self = setUp(); - const result = plan(self, { freshBinding: self.binding }); - assert.equal(result.action, model.OrchestrationAction.DISPATCH_CANDIDATE); - assert.ok(!Object.hasOwn(result.dispatchInputs, 'assets_immutable_releases_enabled')); -}); - -test('test_planner_never_pre_asserts_publication_approval', () => { - const self = setUp(); - const result = plan(self, { - binding: self.binding, - candidateRunId: CANDIDATE_RUN_ID, - qualificationRunId: QUALIFICATION_RUN_ID, - }); - assert.ok(!Object.hasOwn(result.dispatchInputs, 'publish_approved')); -}); - -test('test_missing_binding_for_ready_candidate_fails_closed', () => { - const self = setUp(); - assert.throws(() => plan(self, { candidateRunId: CANDIDATE_RUN_ID }), ContractError); -}); - -test('test_summary_reports_the_exact_action', () => { - const self = setUp(); - const summary = orchestrator.renderStepSummary(plan(self, { freshBinding: self.binding })); - assert.ok(summary.includes('### Stable Web bridge release orchestration')); - assert.ok(summary.includes('dispatch_candidate')); - assert.ok(summary.includes(model.CANDIDATE_WORKFLOW_FILE)); - assert.ok(summary.includes(self.correlationId)); -}); diff --git a/tests/release/orchestrator/py_helpers_test.mjs b/tests/release/orchestrator/py_helpers_test.mjs deleted file mode 100644 index 0be1efb..0000000 --- a/tests/release/orchestrator/py_helpers_test.mjs +++ /dev/null @@ -1,104 +0,0 @@ -// Tests of the Python-compatible helpers the orchestrator port added to -// scripts/release/json.mjs (pyStrptimeUtcOrRaise, the pyJsonDumps separators -// option) and scripts/release/python_compat.mjs (pyFullmatch, pyStrSplit, -// pyCompareIntTuples, PyUtcDatetime). Every expected value was produced by -// CPython 3.12.11, the runners' Python. - -import assert from 'node:assert/strict'; -import { test } from 'node:test'; - -import { pyJsonDumps, pyStrptimeUtcOrRaise } from '../../../scripts/release/json.mjs'; -import { - PyUtcDatetime, pyCompareIntTuples, pyFullmatch, pyStrSplit, -} from '../../../scripts/release/python_compat.mjs'; - -function pyError(fn) { - try { - fn(); - } catch (error) { - return `${error.pyType ?? error.name}: ${error.message}`; - } - return null; -} - -test('strptime raises the ValueError CPython 3.12 raises', () => { - const cases = [ - ['2026-08-19T12:34:56Z', 63922739696], - ['2024-02-29T23:59:59Z', 63844847999], - ['2026-13-01T00:00:00Z', "ValueError: time data '2026-13-01T00:00:00Z' does not match format '%Y-%m-%dT%H:%M:%SZ'"], - ['2026-02-30T00:00:00Z', 'ValueError: day is out of range for month'], - ['2026-01-01T00:00:60Z', 'ValueError: second must be in 0..59'], - ['0000-01-01T00:00:00Z', 'ValueError: year 0 is out of range'], - ['2026-08-19T12:34:56Zx', 'ValueError: unconverted data remains: x'], - ['2026-8-9T1:2:3Z', 63921834123], - ['0001-01-01T00:00:00Z', 0], - ['9999-12-31T23:59:59Z', 315537897599], - ['2026-08-19t12:34:56z', 63922739696], - ]; - for (const [text, expected] of cases) { - if (typeof expected === 'number') { - assert.equal(PyUtcDatetime.strptime(text).seconds, expected, text); - assert.ok(pyStrptimeUtcOrRaise(text), text); - } else { - assert.equal(pyError(() => pyStrptimeUtcOrRaise(text)), expected, text); - assert.equal(pyError(() => PyUtcDatetime.strptime(text)), expected, text); - } - } -}); - -test('datetime arithmetic, strftime and overflow match CPython', () => { - const start = PyUtcDatetime.strptime('2026-08-19T12:34:56Z'); - const expected = [ - [0, '2026-08-19T12:34:56Z'], - [1, '2026-08-19T12:34:57Z'], - [2591999, '2026-09-18T12:34:55Z'], - [-86400 * 365, '2025-08-19T12:34:56Z'], - [86400 * 10000, '2054-01-04T12:34:56Z'], - ]; - for (const [delta, text] of expected) assert.equal(start.addSeconds(delta).strftime(), text, String(delta)); - assert.equal(PyUtcDatetime.strptime('2024-02-29T00:00:00Z').strftime(), '2024-02-29T00:00:00Z'); - // glibc's unpadded %Y, as on the Linux runners. - assert.equal(PyUtcDatetime.strptime('0999-01-01T00:00:00Z').strftime(), '999-01-01T00:00:00Z'); - assert.equal(pyError(() => PyUtcDatetime.strptime('9999-12-31T23:59:59Z').addSeconds(1)), 'OverflowError: date value out of range'); - assert.equal(pyError(() => PyUtcDatetime.strptime('0001-01-01T00:00:00Z').addSeconds(-1)), 'OverflowError: date value out of range'); - // Every day of four centuries round-trips through the civil calendar. - let day = PyUtcDatetime.strptime('1900-01-01T00:00:00Z'); - for (let index = 0; index < 146097; index += 1) { - const text = day.strftime(); - assert.equal(PyUtcDatetime.strptime(text).seconds, day.seconds, text); - day = day.addSeconds(86400); - } - assert.equal(day.strftime(), '2300-01-01T00:00:00Z'); -}); - -test('json.dumps separators', () => { - assert.equal( - pyJsonDumps({ b: [1, { a: 2 }], a: 'x' }, { sortKeys: true, separators: [',', ':'] }), - '{"a":"x","b":[1,{"a":2}]}', - ); - // The defaults are unchanged. - assert.equal(pyJsonDumps({ b: [1, 2], a: 'x' }, { sortKeys: true }), '{"a": "x", "b": [1, 2]}'); - assert.equal(pyJsonDumps({ a: [1] }, { indent: 2 }), '{\n "a": [\n 1\n ]\n}'); -}); - -test('str.split() splits on str.isspace() runs', () => { - const cases = [ - [' a b c\x1c ', ['a', 'b', 'c']], - ['', []], - [' \t\n', []], - ['bridge-candidate x y', ['bridge-candidate', 'x', 'y']], - ['a\x85b
c', ['a', 'b', 'c']], - ]; - for (const [text, fields] of cases) assert.deepEqual(pyStrSplit(text), fields, JSON.stringify(text)); -}); - -test('re.fullmatch rejects a non-str and tuple ordering compares ints', () => { - assert.equal(pyFullmatch(/^a+$/u, 'aaa')[0], 'aaa'); - assert.equal(pyFullmatch(/^a+$/u, 'aab'), null); - assert.equal(pyError(() => pyFullmatch(/^a$/u, 1)), "TypeError: expected string or bytes-like object, got 'int'"); - assert.equal(pyError(() => pyFullmatch(/^a$/u, null)), "TypeError: expected string or bytes-like object, got 'NoneType'"); - assert.ok(pyCompareIntTuples([0, 2, 0, 1], [0, 2, 0, 0]) > 0); - assert.ok(pyCompareIntTuples([0, 2], [0, 2, 0]) < 0); - assert.equal(pyCompareIntTuples([1n, 2], [1, 2n]), 0); - assert.ok(pyCompareIntTuples([2n ** 70n], [Number.MAX_SAFE_INTEGER]) > 0); -}); diff --git a/tests/release/orchestrator/release_tags_test.mjs b/tests/release/orchestrator/release_tags_test.mjs deleted file mode 100644 index e72c35b..0000000 --- a/tests/release/orchestrator/release_tags_test.mjs +++ /dev/null @@ -1,192 +0,0 @@ -// Tests of scripts/release/orchestrator/release_tags.mjs, one test per test -// method of scripts/release_orchestrator_release_tags_test.py, with the same -// names and assertions (a Python subTest is one loop iteration). - -import assert from 'node:assert/strict'; -import { test } from 'node:test'; - -import * as assetReleases from '../../../scripts/release/orchestrator/asset_releases.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as releaseTags from '../../../scripts/release/orchestrator/release_tags.mjs'; -import * as runNames from '../../../scripts/release/orchestrator/run_names.mjs'; -import * as workflowRuns from '../../../scripts/release/orchestrator/workflow_runs.mjs'; -import { - BRIDGE_SHA, - DEFAULT_BRANCH, - assetReleaseStub, - makeLegacyV0140Provenance, - makeProvenance, - runPayload, - runsResponse, -} from './fixtures.mjs'; - -// --- ReleaseTargetTest ----------------------------------------------------------- - -test('test_bridge_assets_version_independently_of_upstream', () => { - const target = releaseTags.selectNextReleaseTarget(['v0.1.38', 'v0.1.39'], { upstreamTag: 'v0.2.0' }); - assert.equal(target.releaseTag, 'v0.1.40'); - assert.equal(target.releaseRebuild, 0); -}); - -test('test_upstream_tag_is_never_used_as_the_output_tag', () => { - const target = releaseTags.selectNextReleaseTarget(['v0.1.39'], { upstreamTag: 'v0.2.0' }); - assert.notEqual(target.releaseTag, 'v0.2.0'); -}); - -test('test_existing_tag_collision_selects_a_free_rebuild', () => { - const target = releaseTags.selectNextReleaseTarget(['v0.1.39', 'v0.1.40', 'v0.1.40-1'], { upstreamTag: 'v0.2.0' }); - assert.equal(target.releaseTag, 'v0.1.41'); - assert.equal(target.releaseRebuild, 0); -}); - -test('test_draft_or_unparsable_tag_collision_is_avoided', () => { - const target = releaseTags.selectNextReleaseTarget(['v0.1.39', 'v0.1.40', 'nightly-scratch'], { upstreamTag: 'v0.2.0' }); - assert.equal(target.releaseTag, 'v0.1.41'); -}); - -test('test_seed_release_when_no_stable_assets_exist', () => { - const target = releaseTags.selectNextReleaseTarget([], { upstreamTag: 'v0.2.0' }); - assert.equal(target.releaseTag, releaseTags.INITIAL_STABLE_RELEASE_TAG); - assert.equal(target.releaseRebuild, 0); -}); - -test('test_taken_next_version_moves_to_the_next_patch_version', () => { - const target = releaseTags.selectNextReleaseTarget(['v0.1.39', 'v0.1.40'], { upstreamTag: 'v0.2.0', taken: new Set(['v0.1.41']) }); - assert.equal(target.releaseTag, 'v0.1.42'); - assert.equal(target.releaseRebuild, 0); -}); - -test('test_claims_set_the_floor_so_tags_follow_dispatch_order', () => { - // v0.1.50 was claimed and released unpublished; v0.1.51 is still in - // flight. A later pipeline must not take v0.1.50 below it. - const target = releaseTags.selectNextReleaseTarget(['v0.1.49'], { upstreamTag: 'v0.5.0', taken: new Set(['v0.1.51']) }); - assert.equal(target.releaseTag, 'v0.1.52'); - assert.equal(target.releaseRebuild, 0); -}); - -test('test_historical_rebuild_tag_sets_the_floor_but_is_never_emitted', () => { - const target = releaseTags.selectNextReleaseTarget(['v0.1.47', 'v0.1.47-1'], { upstreamTag: 'v0.4.1', taken: new Set(['v0.1.48']) }); - assert.equal(target.releaseTag, 'v0.1.49'); - assert.equal(target.releaseRebuild, 0); -}); - -test('test_seed_release_collision_moves_to_the_next_patch_version', () => { - const target = releaseTags.selectNextReleaseTarget([], { - upstreamTag: 'v0.2.0', taken: new Set([releaseTags.INITIAL_STABLE_RELEASE_TAG]), - }); - assert.equal(target.releaseTag, 'v0.1.1'); - assert.equal(target.releaseRebuild, 0); -}); - -test('test_run_history_includes_claims_since_the_last_asset_publication', () => { - const prior = assetReleaseStub(); - prior.published_at = '2026-08-10T00:00:00Z'; - assert.equal(assetReleases.workflowHistorySince([prior], makeProvenance()), '2026-08-10T00:00:00Z'); -}); - -// --- ClaimedReleaseTagsTest -------------------------------------------------------- - -const OTHER_BUILD_SHA = 'b'.repeat(40); - -function claimedSetUp() { - return { - other: runNames.computeCorrelationId(makeProvenance({ bridgeBuildSha: OTHER_BUILD_SHA })), - current: runNames.computeCorrelationId(makeProvenance()), - }; -} - -function candidateName(correlationId, tag, rebuild) { - return runNames.candidateRunName( - correlationId, - new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: tag, releaseRebuild: rebuild }), - ); -} - -function records(workflowPath, ...runs) { - return workflowRuns.parseWorkflowRuns( - runsResponse(runs.map(([runId, name, status, conclusion]) => runPayload({ - runId, path: workflowPath, runName: name, status, conclusion, - }))), - { workflowPath, defaultBranch: DEFAULT_BRANCH }, - ); -} - -test('test_finished_claim_of_another_build_is_dropped', () => { - const self = claimedSetUp(); - for (const conclusion of ['success', 'failure']) { - const runs = records(model.CANDIDATE_WORKFLOW_PATH, ['501', candidateName(self.other, 'v0.1.40', 0), 'completed', conclusion]); - assert.equal(releaseTags.hasOtherBuildClaim(runs, { bridgeBuildSha: BRIDGE_SHA }), true, conclusion); - assert.deepEqual(releaseTags.claimedReleaseTags(runs, { bridgeBuildSha: BRIDGE_SHA }), new Set(), conclusion); - } -}); - -test('a recovery pipeline claim follows its build identity like its stranded pipeline', () => { - const self = claimedSetUp(); - const other = self.other.replace('-build-', '-r1-'); - const current = self.current.replace('-build-', '-r2-'); - const finished = records(model.CANDIDATE_WORKFLOW_PATH, ['501', candidateName(other, 'v0.1.40', 0), 'completed', 'success']); - assert.equal(releaseTags.hasOtherBuildClaim(finished, { bridgeBuildSha: BRIDGE_SHA }), true); - assert.deepEqual(releaseTags.claimedReleaseTags(finished, { bridgeBuildSha: BRIDGE_SHA }), new Set()); - const kept = records(model.CANDIDATE_WORKFLOW_PATH, ['502', candidateName(current, 'v0.1.41', 0), 'completed', 'failure']); - assert.equal(releaseTags.hasOtherBuildClaim(kept, { bridgeBuildSha: BRIDGE_SHA }), false); - assert.deepEqual(releaseTags.claimedReleaseTags(kept, { bridgeBuildSha: BRIDGE_SHA }), new Set(['v0.1.41'])); -}); - -test('test_current_build_claim_is_kept', () => { - const self = claimedSetUp(); - const runs = records(model.CANDIDATE_WORKFLOW_PATH, ['501', candidateName(self.current, 'v0.1.40', 0), 'completed', 'failure']); - assert.equal(releaseTags.hasOtherBuildClaim(runs, { bridgeBuildSha: BRIDGE_SHA }), false); - assert.deepEqual(releaseTags.claimedReleaseTags(runs, { bridgeBuildSha: BRIDGE_SHA }), new Set(['v0.1.40'])); -}); - -test('test_claim_without_a_build_identity_is_kept', () => { - const legacy = runNames.computeCorrelationId(makeLegacyV0140Provenance()); - assert.ok(!legacy.includes('-build-')); - const runs = records(model.CANDIDATE_WORKFLOW_PATH, ['501', candidateName(legacy, 'v0.1.40', 0), 'completed', 'success']); - assert.deepEqual(releaseTags.claimedReleaseTags(runs, { bridgeBuildSha: BRIDGE_SHA }), new Set(['v0.1.40'])); -}); - -test('test_in_flight_candidate_of_another_build_keeps_its_claim', () => { - const self = claimedSetUp(); - const runs = records(model.CANDIDATE_WORKFLOW_PATH, ['501', candidateName(self.other, 'v0.1.40', 0), 'in_progress', null]); - assert.deepEqual(releaseTags.claimedReleaseTags(runs, { bridgeBuildSha: BRIDGE_SHA }), new Set(['v0.1.40'])); -}); - -test('test_in_flight_downstream_run_of_another_build_keeps_its_claim', () => { - const self = claimedSetUp(); - const candidates = records(model.CANDIDATE_WORKFLOW_PATH, ['501', candidateName(self.other, 'v0.1.40', 0), 'completed', 'success']); - const binding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.40', releaseRebuild: 0 }); - const downstream = [ - [model.QUALIFICATION_WORKFLOW_PATH, runNames.qualificationRunName(self.other, '501')], - [model.PUBLISH_WORKFLOW_PATH, runNames.publishRunName(self.other, '501', '601', binding)], - ]; - for (const [workflowPath, name] of downstream) { - const inFlight = records(workflowPath, ['701', name, 'queued', null]); - assert.deepEqual( - releaseTags.claimedReleaseTags(candidates, { bridgeBuildSha: BRIDGE_SHA, downstreamRuns: inFlight }), - new Set(['v0.1.40']), - workflowPath, - ); - const finished = records(workflowPath, ['701', name, 'completed', 'failure']); - assert.deepEqual( - releaseTags.claimedReleaseTags(candidates, { bridgeBuildSha: BRIDGE_SHA, downstreamRuns: finished }), - new Set(), - workflowPath, - ); - } -}); - -test('test_satisfied_correlation_releases_its_claim', () => { - const self = claimedSetUp(); - const sibling = runNames.computeCorrelationId(makeProvenance({ nativeReleaseTag: 'v0.2.1', nativeManifestSha256: 'f'.repeat(64) })); - const runs = records( - model.CANDIDATE_WORKFLOW_PATH, - ['501', candidateName(self.current, 'v0.1.40', 0), 'completed', 'success'], - ['502', candidateName(sibling, 'v0.1.40-1', 1), 'completed', 'success'], - ); - assert.deepEqual(releaseTags.claimedReleaseTags(runs, { bridgeBuildSha: BRIDGE_SHA }), new Set(['v0.1.40', 'v0.1.40-1'])); - assert.deepEqual( - releaseTags.claimedReleaseTags(runs, { bridgeBuildSha: BRIDGE_SHA, satisfiedCorrelationIds: new Set([self.current]) }), - new Set(['v0.1.40-1']), - ); -}); diff --git a/tests/release/orchestrator/run_names_test.mjs b/tests/release/orchestrator/run_names_test.mjs deleted file mode 100644 index 0bf24c8..0000000 --- a/tests/release/orchestrator/run_names_test.mjs +++ /dev/null @@ -1,205 +0,0 @@ -// Tests of scripts/release/orchestrator/run_names.mjs, one test per test method -// of scripts/release_orchestrator_run_names_test.py, with the same names and -// assertions (a Python subTest is one loop iteration). - -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { test } from 'node:test'; -import { fileURLToPath } from 'node:url'; - -import { ContractError } from '../../../scripts/release/contract.mjs'; -import { pySplitlines } from '../../../scripts/release/json.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as runNames from '../../../scripts/release/orchestrator/run_names.mjs'; -import { - ADVANCED_BRIDGE_SHA, - BRIDGE_SHA, - CANDIDATE_RUN_ID, - NATIVE_MANIFEST_SHA, - QUALIFICATION_RUN_ID, - makeLegacyV0140Provenance, - makeProvenance, -} from './fixtures.mjs'; - -const WORKFLOWS = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../.github/workflows'); - -// --- CorrelationTest ------------------------------------------------------------- - -test('test_deterministic_correlation_id_ignores_orchestration_only_main', () => { - const first = runNames.computeCorrelationId(makeProvenance()); - const second = runNames.computeCorrelationId(makeProvenance({ bridgeSourceSha: ADVANCED_BRIDGE_SHA })); - assert.equal(first, second); - assert.equal(first, `auto-stable-v0.2.0-${NATIVE_MANIFEST_SHA.slice(0, 16)}-build-${BRIDGE_SHA.slice(0, 16)}`); -}); - -test('test_correlation_id_changes_with_governed_build_source', () => { - const other = runNames.computeCorrelationId(makeProvenance({ - bridgeSourceSha: ADVANCED_BRIDGE_SHA, - bridgeBuildSha: ADVANCED_BRIDGE_SHA, - })); - assert.notEqual(runNames.computeCorrelationId(makeProvenance()), other); -}); - -test('test_correlation_id_changes_with_native_manifest', () => { - const other = runNames.computeCorrelationId(makeProvenance({ nativeManifestSha256: 'b'.repeat(64) })); - assert.notEqual(runNames.computeCorrelationId(makeProvenance()), other); -}); - -test('a recovery generation is a distinct correlation that reads back to its stranded pipeline', () => { - const base = runNames.computeCorrelationId(makeProvenance()); - assert.equal(runNames.computeCorrelationId(makeProvenance(), { recovery: 0 }), base); - const recovered = [1, 2, 99].map((recovery) => runNames.computeCorrelationId(makeProvenance(), { recovery })); - assert.deepEqual(recovered, [1, 2, 99].map((recovery) => base.replace('-build-', `-r${recovery}-`))); - for (const correlationId of [base, ...recovered]) { - assert.equal(runNames.correlationRecoveryBase(correlationId), base); - assert.deepEqual(runNames.correlationNativeIdentity(correlationId), { - nativeReleaseTag: 'v0.2.0', nativeManifestPrefix: NATIVE_MANIFEST_SHA.slice(0, 16), - }); - } - // Array.map passes the index as the second argument. - assert.deepEqual([makeProvenance(), makeProvenance()].map(runNames.computeCorrelationId), [base, base]); - for (const recovery of [-1, 1.5, NaN, 100]) { - assert.throws(() => runNames.computeCorrelationId(makeProvenance(), { recovery }), ContractError, String(recovery)); - } - const legacy = makeLegacyV0140Provenance(); - assert.equal( - runNames.computeCorrelationId(legacy, { recovery: 1 }), - `auto-stable-v0.3.0-${legacy.nativeManifestSha256.slice(0, 16)}-r1-${legacy.bridgeBuildSha.slice(0, 16)}`, - ); - for (const generation of ['0', '01', '100', '']) { - const other = base.replace('-build-', `-r${generation}-`); - assert.equal(runNames.correlationNativeIdentity(other), null, other); - assert.equal(runNames.correlationRecoveryBase(other), other, other); - } -}); - -test('a recovery publish run name fits wherever its stranded pipeline name fits', () => { - // Production-size run IDs and the longest native tag shape in use. - const provenance = makeProvenance({ nativeReleaseTag: 'v0.10.0-12' }); - const binding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.100', releaseRebuild: 0 }); - const name = (recovery) => runNames.publishRunName( - runNames.computeCorrelationId(provenance, { recovery }), '40668062533', '40670661678', binding, - ); - for (const recovery of [1, 2, 3, 99]) assert.ok(name(recovery).length <= name(0).length, String(recovery)); - assert.ok(name(0).length <= runNames.MAX_RUN_NAME_CHARACTERS); -}); - -test('correlation native identity reads back what computeCorrelationId used', () => { - for (const nativeReleaseTag of ['v0.2.0', 'v0.7.0-2', 'v10.20.30-11']) { - const correlationId = runNames.computeCorrelationId(makeProvenance({ nativeReleaseTag })); - assert.deepEqual(runNames.correlationNativeIdentity(correlationId), { - nativeReleaseTag, nativeManifestPrefix: NATIVE_MANIFEST_SHA.slice(0, 16), - }); - } - const legacy = makeLegacyV0140Provenance(); - assert.deepEqual(runNames.correlationNativeIdentity(runNames.computeCorrelationId(legacy)), { - nativeReleaseTag: 'v0.3.0', nativeManifestPrefix: legacy.nativeManifestSha256.slice(0, 16), - }); - for (const other of ['kanban:t_7f112b91:web-v0.1.39', 'auto-stable-b8000-2e5d29d7f98f0d71', 'auto-stable-v0.2.0-0-2e5d29d7f98f0d71', - 'auto-stable-v0.07.0-2e5d29d7f98f0d71', null]) { - assert.equal(runNames.correlationNativeIdentity(other), null, String(other)); - } -}); - -test('test_candidate_run_name_round_trips_persisted_binding', () => { - const correlationId = runNames.computeCorrelationId(makeProvenance()); - const binding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.40', releaseRebuild: 0 }); - const name = runNames.candidateRunName(correlationId, binding); - assert.equal(name, `bridge-candidate ${correlationId} source:${BRIDGE_SHA} tag:v0.1.40 rebuild:0`); - assert.ok(runNames.parseCandidateRunName(name, correlationId).equals(binding)); -}); - -test('test_foreign_candidate_run_name_is_not_adopted', () => { - const correlationId = runNames.computeCorrelationId(makeProvenance()); - const foreignNames = [ - `bridge-candidate other-correlation source:${BRIDGE_SHA} tag:v0.1.40 rebuild:0`, - 'bridge-candidate other-correlation source:not-a-sha tag:v0.1.40 rebuild:0', - 'bridge-candidate other-correlation malformed', - `bridge-candidate ${correlationId}-foreign source:not-a-sha tag:v0.1.40 rebuild:bad`, - `bridge-candidate-other ${correlationId} malformed`, - 'some-unrelated-workflow-run', - ]; - for (const foreignName of foreignNames) { - assert.equal(runNames.parseCandidateRunName(foreignName, correlationId), null, foreignName); - } -}); - -test('test_malformed_candidate_run_name_claiming_correlation_fails_closed', () => { - const correlationId = runNames.computeCorrelationId(makeProvenance()); - const malformedNames = [ - `bridge-candidate ${correlationId} source:not-a-sha tag:v0.1.40 rebuild:0`, - `bridge-candidate ${correlationId} source:${BRIDGE_SHA} tag:v0.1.40 rebuild:not-a-number`, - `bridge-candidate ${correlationId} source:${BRIDGE_SHA} tag:v0.1.40 rebuild:-1`, - `bridge-candidate ${correlationId} source:${BRIDGE_SHA} tag:v0.1.40 rebuild:01`, - `bridge-candidate ${correlationId} source:${BRIDGE_SHA} tag:invalid_tag rebuild:0`, - `bridge-candidate ${correlationId}`, - `bridge-candidate ${correlationId} source:${BRIDGE_SHA}`, - `bridge-candidate ${correlationId} source:${BRIDGE_SHA} tag:v0.1.40`, - `bridge-candidate ${correlationId} malformed`, - `bridge-candidate ${correlationId} source:${BRIDGE_SHA} tag:v0.1.40 rebuild:0\x00`, - `bridge-candidate ${correlationId} ${'x'.repeat(200)}`, - ]; - for (const malformedName of malformedNames) { - assert.throws(() => runNames.parseCandidateRunName(malformedName, correlationId), ContractError, malformedName); - } -}); - -test('test_generated_run_name_enforces_a_conservative_length_bound', () => { - assert.throws(() => runNames.requireRunName('x'.repeat(runNames.MAX_RUN_NAME_CHARACTERS + 1)), ContractError); -}); - -// --- WorkflowRunNameContractTest: the rendered workflow run-name must be -// exactly what the parser expects. ----------------------------------------------- - -function render(workflow, inputs) { - for (const line of pySplitlines(fs.readFileSync(path.join(WORKFLOWS, workflow), 'utf8'))) { - if (!line.startsWith('run-name: ')) continue; - let rendered = line.slice('run-name: '.length); - for (const [name, value] of Object.entries(inputs)) { - rendered = rendered.replaceAll(`\${{ inputs.${name} }}`, value); - } - assert.ok(!rendered.includes('${{'), rendered); - return rendered; - } - throw new assert.AssertionError({ message: `${workflow} declares no run-name` }); -} - -test('test_candidate_workflow_renders_the_parsed_run_name', () => { - const correlationId = runNames.computeCorrelationId(makeProvenance()); - const binding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.40', releaseRebuild: 0 }); - const rendered = render(model.CANDIDATE_WORKFLOW_FILE, { - orchestrator_correlation_id: correlationId, - bridge_source_sha: BRIDGE_SHA, - release_tag: 'v0.1.40', - release_rebuild: '0', - }); - assert.equal(rendered, runNames.candidateRunName(correlationId, binding)); - assert.ok(runNames.parseCandidateRunName(rendered, correlationId).equals(binding)); -}); - -test('test_qualification_workflow_renders_the_parsed_run_name', () => { - const correlationId = runNames.computeCorrelationId(makeProvenance()); - const rendered = render(model.QUALIFICATION_WORKFLOW_FILE, { - orchestrator_correlation_id: correlationId, - candidate_run_id: CANDIDATE_RUN_ID, - }); - assert.equal(rendered, runNames.qualificationRunName(correlationId, CANDIDATE_RUN_ID)); -}); - -test('test_publish_workflow_renders_the_parsed_run_name', () => { - const correlationId = runNames.computeCorrelationId(makeProvenance()); - const binding = new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.40', releaseRebuild: 0 }); - const rendered = render(model.PUBLISH_WORKFLOW_FILE, { - orchestrator_correlation_id: correlationId, - candidate_run_id: CANDIDATE_RUN_ID, - qualification_run_id: QUALIFICATION_RUN_ID, - bridge_source_sha: BRIDGE_SHA, - release_tag: 'v0.1.40', - release_rebuild: '0', - }); - assert.equal(rendered, runNames.publishRunName(correlationId, CANDIDATE_RUN_ID, QUALIFICATION_RUN_ID, binding)); - const [candidateRunId, qualificationRunId, parsed] = runNames.parsePublishRunName(rendered, correlationId); - assert.deepEqual([candidateRunId, qualificationRunId], [CANDIDATE_RUN_ID, QUALIFICATION_RUN_ID]); - assert.ok(parsed.equals(binding)); -}); diff --git a/tests/release/orchestrator/transport_test.mjs b/tests/release/orchestrator/transport_test.mjs deleted file mode 100644 index aeeb43d..0000000 --- a/tests/release/orchestrator/transport_test.mjs +++ /dev/null @@ -1,158 +0,0 @@ -// Tests of scripts/release/orchestrator/transport.mjs, one test per test method -// of scripts/release_orchestrator_transport_test.py, with the same names and -// assertions. The real argv/stdin boundary is exercised through a fake `gh` -// on a private PATH passed as the gateway's environment, so the tests never -// touch process.env and run in parallel. - -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import process from 'node:process'; -import { test } from 'node:test'; - -import { ASSETS_REPOSITORY, BRIDGE_REPOSITORY, ContractError } from '../../../scripts/release/contract.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as transport from '../../../scripts/release/orchestrator/transport.mjs'; - -// A fake gh that logs its argv, stdin and token environment, then prints -// GH_FAKE_STDOUT and GH_FAKE_STDERR and exits with GH_FAKE_EXIT. -function fakeGh(directory) { - const executable = path.join(directory, 'gh'); - const log = path.join(directory, 'gh-log.json'); - fs.writeFileSync(executable, `#!/usr/bin/env node -const fs = require('node:fs'); -// Only a dispatch writes stdin; every other call inherits the runner's stdin, -// which may never reach end of file. -const stdin = process.argv[2] === 'workflow' ? fs.readFileSync(0, 'utf8') : ''; -fs.writeFileSync(process.env.GH_FAKE_LOG, JSON.stringify({ - argv: process.argv.slice(2), - stdin, - gh_token: process.env.GH_TOKEN ?? null, - github_token_present: 'GITHUB_TOKEN' in process.env, -})); -process.stdout.write(process.env.GH_FAKE_STDOUT ?? ''); -process.stderr.write(process.env.GH_FAKE_STDERR ?? ''); -process.exitCode = Number(process.env.GH_FAKE_EXIT ?? '0'); -`, { mode: 0o700 }); - fs.writeFileSync(path.join(directory, 'package.json'), '{"type": "commonjs"}'); - return [executable, log]; -} - -function withTemporaryDirectory(fn) { - const directory = fs.mkdtempSync(path.join(tmpdir(), 'sro-transport-')); - try { - return fn(directory); - } finally { - fs.rmSync(directory, { recursive: true, force: true }); - } -} - -function environment(directory, extra) { - return { ...process.env, PATH: `${directory}${path.delimiter}${process.env.PATH ?? ''}`, ...extra }; -} - -test('test_workflow_dispatch_uses_json_stdin_and_accepts_url_stdout', () => { - const observed = withTemporaryDirectory((directory) => { - const [, log] = fakeGh(directory); - const env = environment(directory, { - GH_FAKE_LOG: log, - GH_FAKE_STDOUT: `https://github.com/${BRIDGE_REPOSITORY}/actions/runs/501\n`, - GITHUB_TOKEN: 'must-be-removed', - }); - const gateway = new transport.GhGateway({ readToken: 'read-token', dispatchToken: 'dispatch-token', env }); - gateway.dispatchWorkflow({ - workflowFile: model.CANDIDATE_WORKFLOW_FILE, - ref: 'main', - inputs: { zeta: 'last', alpha: 'first' }, - }); - return JSON.parse(fs.readFileSync(log, 'utf8')); - }); - assert.deepEqual(observed.argv, [ - 'workflow', - 'run', - model.CANDIDATE_WORKFLOW_FILE, - '--repo', - BRIDGE_REPOSITORY, - '--ref', - 'main', - '--json', - ]); - assert.deepEqual(JSON.parse(observed.stdin), { alpha: 'first', zeta: 'last' }); - // json.dumps(inputs, sort_keys=True), byte for byte. - assert.equal(observed.stdin, '{"alpha": "first", "zeta": "last"}'); - assert.equal(observed.gh_token, 'dispatch-token'); - assert.equal(observed.github_token_present, false); -}); - -test('test_release_verify_uses_json_format_and_read_credential', () => { - const observed = withTemporaryDirectory((directory) => { - const [, log] = fakeGh(directory); - const env = environment(directory, { GH_FAKE_LOG: log, GH_FAKE_STDOUT: '{}\n' }); - const gateway = new transport.GhGateway({ readToken: 'read-token', dispatchToken: 'dispatch-token', env }); - assert.deepEqual(gateway.releaseAttestation({ repository: ASSETS_REPOSITORY, releaseTag: 'v0.1.40' }), {}); - return JSON.parse(fs.readFileSync(log, 'utf8')); - }); - assert.deepEqual(observed.argv, [ - 'release', - 'verify', - 'v0.1.40', - '--repo', - ASSETS_REPOSITORY, - '--format', - 'json', - ]); - assert.equal(observed.gh_token, 'read-token'); -}); - -// Node-only checks of the rest of the GhGateway surface, beyond the Python -// suite: the api argv and token split, failure text, and the clock format. -test('node_api_json_paginates_and_splits_tokens', () => { - withTemporaryDirectory((directory) => { - const [, log] = fakeGh(directory); - const env = environment(directory, { GH_FAKE_LOG: log, GH_FAKE_STDOUT: '[[1, 2.0]]' }); - const gateway = new transport.GhGateway({ readToken: 'read-token', dispatchToken: 'dispatch-token', env }); - const payload = gateway.apiJson('repos/x/y/releases?per_page=100', { paginate: true }); - assert.equal(payload.length, 1); - let observed = JSON.parse(fs.readFileSync(log, 'utf8')); - assert.deepEqual(observed.argv, ['api', '--paginate', '--slurp', 'repos/x/y/releases?per_page=100']); - assert.equal(observed.gh_token, 'read-token'); - gateway.downloadBytes('repos/x/y/zip', { accept: 'application/zip', privileged: true }); - observed = JSON.parse(fs.readFileSync(log, 'utf8')); - assert.deepEqual(observed.argv, ['api', '-H', 'Accept: application/zip', 'repos/x/y/zip']); - assert.equal(observed.gh_token, 'dispatch-token'); - }); -}); - -test('node_failures_and_missing_credentials_fail_closed', () => { - withTemporaryDirectory((directory) => { - const [, log] = fakeGh(directory); - const env = environment(directory, { GH_FAKE_LOG: log, GH_FAKE_EXIT: '1', GH_FAKE_STDERR: ' HTTP 404: Not Found\n' }); - const gateway = new transport.GhGateway({ readToken: 'read-token', dispatchToken: '', env }); - assert.throws(() => gateway.apiJson('user'), { name: 'ContractError', message: 'gh api user failed: HTTP 404: Not Found' }); - assert.throws( - () => gateway.apiJson('user', { privileged: true }), - new ContractError('no credential is available for this GitHub request; refusing to continue'), - ); - assert.equal(gateway.dispatchIdentity(), null); - assert.throws( - () => gateway.dispatchWorkflow({ workflowFile: 'x.yml', ref: 'main', inputs: {} }), - new ContractError('workflow dispatch requires an orchestrator credential'), - ); - const dispatcher = new transport.GhGateway({ readToken: 'read-token', dispatchToken: 'dispatch-token', env }); - assert.throws( - () => dispatcher.dispatchWorkflow({ workflowFile: 'x.yml', ref: 'main', inputs: {} }), - new ContractError('dispatching x.yml failed: HTTP 404: Not Found'), - ); - assert.equal(dispatcher.dispatchIdentity(), null); - }); -}); - -test('node_utc_now_uses_the_canonical_format', () => { - const gateway = new transport.GhGateway({ readToken: 'r', dispatchToken: null, clock: () => new Date(Date.UTC(2026, 7, 30, 1, 2, 3, 999)) }); - assert.equal(gateway.utcNow(), '2026-08-30T01:02:03Z'); - assert.match(new transport.GhGateway({ readToken: 'r', dispatchToken: null }).utcNow(), /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u); - const slept = []; - new transport.GhGateway({ readToken: 'r', dispatchToken: null, sleeper: (seconds) => slept.push(seconds) }).sleep(5); - assert.deepEqual(slept, [5]); -}); diff --git a/tests/release/orchestrator/workflow_runs_test.mjs b/tests/release/orchestrator/workflow_runs_test.mjs deleted file mode 100644 index d3c2d07..0000000 --- a/tests/release/orchestrator/workflow_runs_test.mjs +++ /dev/null @@ -1,293 +0,0 @@ -// Tests of scripts/release/orchestrator/workflow_runs.mjs, one test per test -// method of scripts/release_orchestrator_workflow_runs_test.py, with the same -// names and assertions (a Python subTest is one loop iteration). - -import assert from 'node:assert/strict'; -import { test } from 'node:test'; - -import { ContractError } from '../../../scripts/release/contract.mjs'; -import * as model from '../../../scripts/release/orchestrator/model.mjs'; -import * as runNames from '../../../scripts/release/orchestrator/run_names.mjs'; -import * as workflowRuns from '../../../scripts/release/orchestrator/workflow_runs.mjs'; -import { - BRIDGE_SHA, - CANDIDATE_RUN_ID, - DEFAULT_BRANCH, - FakeGateway, - NATIVE_PUBLISHED_AT, - OWNER, - makeProvenance, - runPayload, - runsResponse, -} from './fixtures.mjs'; - -// --- WorkflowRunsResponseTest ---------------------------------------------------- - -// setUp: the candidate path, its run name and one successful run payload. -function responseSetUp() { - const workflowPath = model.CANDIDATE_WORKFLOW_PATH; - const correlationId = runNames.computeCorrelationId(makeProvenance()); - const runName = runNames.candidateRunName( - correlationId, - new model.PipelineBinding({ bridgeSourceSha: BRIDGE_SHA, releaseTag: 'v0.1.40', releaseRebuild: 0 }), - ); - const run = runPayload({ runId: CANDIDATE_RUN_ID, path: workflowPath, runName }); - return { path: workflowPath, runName, run }; -} - -function candidateRunsPath(options = {}) { - return workflowRuns.workflowRunsPath({ - workflowFile: model.CANDIDATE_WORKFLOW_FILE, - defaultBranch: DEFAULT_BRANCH, - createdSince: NATIVE_PUBLISHED_AT, - ...options, - }); -} - -function fetchCandidateRuns(gateway, workflowPath) { - return workflowRuns.fetchRuns(gateway, { - workflowFile: model.CANDIDATE_WORKFLOW_FILE, - workflowPath, - defaultBranch: DEFAULT_BRANCH, - createdSince: NATIVE_PUBLISHED_AT, - }); -} - -test('test_dynamic_correlated_api_name_is_parsed', () => { - const self = responseSetUp(); - assert.equal(self.run.name, self.runName); - assert.notEqual(self.run.name, 'Build Exact Bridge Candidate'); - const runs = workflowRuns.parseWorkflowRuns(runsResponse([self.run]), { workflowPath: self.path, defaultBranch: DEFAULT_BRANCH }); - assert.deepEqual(runs.map((record) => record.runId), [CANDIDATE_RUN_ID]); -}); - -test('test_bare_list_response_is_rejected', () => { - const self = responseSetUp(); - assert.throws(() => workflowRuns.parseWorkflowRuns([self.run], { workflowPath: self.path, defaultBranch: DEFAULT_BRANCH }), ContractError); -}); - -test('test_truncated_page_set_fails_closed', () => { - const self = responseSetUp(); - assert.throws(() => workflowRuns.parseWorkflowRuns( - { total_count: 5000, workflow_runs: [self.run] }, - { workflowPath: self.path, defaultBranch: DEFAULT_BRANCH }, - ), ContractError); -}); - -test('test_foreign_workflow_path_fails_closed', () => { - const self = responseSetUp(); - const foreign = runPayload({ - runId: CANDIDATE_RUN_ID, - path: '.github/workflows/ci.yml', - runName: self.runName, - apiName: self.runName, - }); - assert.throws(() => workflowRuns.parseWorkflowRuns(runsResponse([foreign]), { workflowPath: self.path, defaultBranch: DEFAULT_BRANCH }), ContractError); -}); - -test('test_unsupported_workflow_path_fails_closed_even_when_record_matches', () => { - const self = responseSetUp(); - const unsupportedPath = '.github/workflows/ci.yml'; - const unsupported = { ...self.run, path: unsupportedPath }; - assert.throws(() => workflowRuns.parseWorkflowRuns( - runsResponse([unsupported]), - { workflowPath: unsupportedPath, defaultBranch: DEFAULT_BRANCH }, - ), ContractError); -}); - -test('test_non_owner_actor_or_triggering_actor_fails_closed', () => { - const self = responseSetUp(); - for (const field of ['actor', 'triggering_actor']) { - const malformed = { ...self.run, [field]: { login: 'someone-else' } }; - assert.throws(() => workflowRuns.parseWorkflowRuns( - runsResponse([malformed]), - { workflowPath: self.path, defaultBranch: DEFAULT_BRANCH }, - ), ContractError, field); - } -}); - -test('test_duplicate_run_ids_fail_closed', () => { - const self = responseSetUp(); - assert.throws(() => workflowRuns.parseWorkflowRuns( - { total_count: 2, workflow_runs: [self.run, { ...self.run }] }, - { workflowPath: self.path, defaultBranch: DEFAULT_BRANCH }, - ), ContractError); -}); - -test('test_server_query_bounds_only_relevant_owner_branch_history', () => { - const runsPath = candidateRunsPath(); - assert.ok(runsPath.includes('per_page=100')); - assert.ok(runsPath.includes('event=workflow_dispatch')); - assert.ok(runsPath.includes('branch=main')); - assert.ok(runsPath.includes(`actor=${OWNER}`)); - assert.ok(runsPath.includes('created=%3E%3D2026-08-19T12%3A34%3A56Z')); -}); - -test('test_filtered_history_paginates_beyond_one_hundred_without_global_count', () => { - const self = responseSetUp(); - const payloads = Array.from({ length: 101 }, (_, index) => runPayload({ - runId: String(1000 + index), path: self.path, runName: `unrelated-${index}`, - })); - const gateway = new FakeGateway({ - jsonRoutes: { - [candidateRunsPath()]: { total_count: 101, workflow_runs: payloads.slice(0, 100) }, - [candidateRunsPath({ page: 2 })]: { total_count: 101, workflow_runs: payloads.slice(100) }, - }, - }); - const records = fetchCandidateRuns(gateway, self.path); - assert.equal(records.length, 101); - assert.deepEqual( - new Set(records.map((record) => record.runId)), - new Set(Array.from({ length: 101 }, (_, index) => String(1000 + index))), - ); -}); - -test('test_page_total_change_fails_closed', () => { - const self = responseSetUp(); - const payloads = Array.from({ length: 101 }, (_, index) => runPayload({ - runId: String(2000 + index), path: self.path, runName: `unrelated-${index}`, - })); - const gateway = new FakeGateway({ - jsonRoutes: { - [candidateRunsPath()]: { total_count: 101, workflow_runs: payloads.slice(0, 100) }, - [candidateRunsPath({ page: 2 })]: { total_count: 102, workflow_runs: payloads.slice(100) }, - }, - }); - assert.throws(() => fetchCandidateRuns(gateway, self.path), ContractError); -}); - -test('test_saturated_search_is_split_into_complete_time_windows', () => { - const self = responseSetUp(); - const end = '2026-08-19T12:34:57Z'; - const payloads = Array.from({ length: 1000 }, (_, index) => runPayload({ - runId: String(3000 + index), path: self.path, runName: `windowed-${index}`, - })); - const routes = {}; - routes[candidateRunsPath()] = { total_count: 1000, workflow_runs: payloads.slice(0, 100) }; - routes[candidateRunsPath({ createdUntil: end })] = { total_count: 1000, workflow_runs: payloads.slice(0, 100) }; - for (const [start, stop, windowStart, windowEnd] of [ - [0, 600, NATIVE_PUBLISHED_AT, NATIVE_PUBLISHED_AT], - [600, 1000, end, end], - ]) { - const total = stop - start; - let page = 1; - for (let offset = start; offset < stop; offset += 100, page += 1) { - const windowPath = workflowRuns.workflowRunsPath({ - workflowFile: model.CANDIDATE_WORKFLOW_FILE, - defaultBranch: DEFAULT_BRANCH, - createdSince: windowStart, - createdUntil: windowEnd, - page: page > 1 ? page : null, - }); - routes[windowPath] = { total_count: total, workflow_runs: payloads.slice(offset, Math.min(offset + 100, stop)) }; - } - } - const gateway = new FakeGateway({ jsonRoutes: routes, now: end }); - const records = fetchCandidateRuns(gateway, self.path); - assert.equal(records.length, 1000); -}); - -// --- RunSelectionTest ------------------------------------------------------------ - -function selectionRuns(...namesAndStates) { - const payloads = namesAndStates.map(([runId, name, status, conclusion]) => runPayload({ - runId, path: model.CANDIDATE_WORKFLOW_PATH, runName: name, status, conclusion, - })); - return workflowRuns.parseWorkflowRuns(runsResponse(payloads), { - workflowPath: model.CANDIDATE_WORKFLOW_PATH, - defaultBranch: DEFAULT_BRANCH, - }); -} - -const isTarget = (name) => name === 'target'; - -test('test_single_success_is_selected', () => { - const runs = selectionRuns(['501', 'target', 'completed', 'success']); - const selection = workflowRuns.selectPipelineRuns(runs, { label: 'candidate', matcher: isTarget }); - assert.equal(selection.succeededRunId, '501'); - assert.equal(selection.inFlightRunId, null); -}); - -test('test_duplicate_success_fails_closed', () => { - const runs = selectionRuns(['501', 'target', 'completed', 'success'], ['502', 'target', 'completed', 'success']); - assert.throws(() => workflowRuns.selectPipelineRuns(runs, { label: 'candidate', matcher: isTarget }), ContractError); -}); - -test('test_duplicate_in_flight_fails_closed', () => { - const runs = selectionRuns(['501', 'target', 'in_progress', null], ['502', 'target', 'queued', null]); - assert.throws(() => workflowRuns.selectPipelineRuns(runs, { label: 'candidate', matcher: isTarget }), ContractError); -}); - -test('test_success_racing_a_new_dispatch_fails_closed', () => { - const runs = selectionRuns(['501', 'target', 'completed', 'success'], ['502', 'target', 'in_progress', null]); - assert.throws(() => workflowRuns.selectPipelineRuns(runs, { label: 'candidate', matcher: isTarget }), ContractError); -}); - -test('test_failed_runs_are_retryable_not_selected', () => { - const runs = selectionRuns(['501', 'target', 'completed', 'failure']); - const selection = workflowRuns.selectPipelineRuns(runs, { label: 'candidate', matcher: isTarget }); - assert.equal(selection.succeededRunId, null); - assert.equal(selection.inFlightRunId, null); - assert.deepEqual(selection.unsuccessful.map((record) => record.runId), ['501']); -}); - -test('test_second_attempt_fails_closed', () => { - const payload = runPayload({ - runId: '501', path: model.CANDIDATE_WORKFLOW_PATH, runName: 'target', runAttempt: 2, - }); - const runs = workflowRuns.parseWorkflowRuns(runsResponse([payload]), { - workflowPath: model.CANDIDATE_WORKFLOW_PATH, - defaultBranch: DEFAULT_BRANCH, - }); - assert.throws(() => workflowRuns.selectPipelineRuns(runs, { label: 'candidate', matcher: isTarget }), ContractError); -}); - -test('test_off_main_line_run_fails_closed', () => { - const payload = runPayload({ - runId: '501', path: model.CANDIDATE_WORKFLOW_PATH, runName: 'target', headBranch: 'attacker', - }); - const runs = workflowRuns.parseWorkflowRuns(runsResponse([payload]), { - workflowPath: model.CANDIDATE_WORKFLOW_PATH, - defaultBranch: DEFAULT_BRANCH, - }); - assert.throws(() => workflowRuns.selectPipelineRuns(runs, { label: 'candidate', matcher: isTarget }), ContractError); -}); - -test('test_candidate_matcher_and_selection_fail_closed_on_correlated_malformed_run', () => { - const correlationId = runNames.computeCorrelationId(makeProvenance()); - const matcher = workflowRuns.candidateMatcher(correlationId); - const malformedNames = [ - `bridge-candidate ${correlationId} source:not-a-sha tag:v0.1.40 rebuild:0`, - `bridge-candidate ${correlationId} source:${BRIDGE_SHA} tag:v0.1.40 rebuild:bad`, - `bridge-candidate ${correlationId} source:${BRIDGE_SHA} tag:invalid_tag rebuild:0`, - `bridge-candidate ${correlationId} source:${BRIDGE_SHA} tag:v0.1.40`, - `bridge-candidate ${correlationId} malformed`, - `bridge-candidate ${correlationId} source:${BRIDGE_SHA} tag:v0.1.40 rebuild:0\x00`, - ]; - for (const malformedName of malformedNames) { - assert.throws(() => matcher(malformedName), ContractError, malformedName); - const runs = selectionRuns(['501', malformedName, 'in_progress', null]); - assert.throws(() => workflowRuns.selectPipelineRuns(runs, { label: 'candidate', matcher }), ContractError, malformedName); - } -}); - -test('test_candidate_matcher_and_selection_ignore_foreign_candidate_run', () => { - const correlationId = runNames.computeCorrelationId(makeProvenance()); - const matcher = workflowRuns.candidateMatcher(correlationId); - const foreignNames = [ - `bridge-candidate other-correlation source:${BRIDGE_SHA} tag:v0.1.40 rebuild:0`, - 'bridge-candidate other-correlation source:not-a-sha tag:v0.1.40 rebuild:0', - 'bridge-candidate other-correlation malformed', - `bridge-candidate ${correlationId}-foreign source:not-a-sha tag:v0.1.40 rebuild:bad`, - `bridge-candidate-other ${correlationId} malformed`, - 'completely-unrelated-workflow-run', - ]; - for (const foreignName of foreignNames) { - assert.equal(matcher(foreignName), false, foreignName); - const runs = selectionRuns(['501', foreignName, 'in_progress', null]); - const selection = workflowRuns.selectPipelineRuns(runs, { label: 'candidate', matcher }); - assert.equal(selection.matched.length, 0); - assert.equal(selection.inFlightRunId, null); - assert.equal(selection.succeededRunId, null); - } -});