diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ee24f87..345a9a6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -258,6 +258,61 @@ jobs: npm run npm:version npm run platform:smoke + platform-smoke-linux-arm64: + name: quality / platform smoke / linux-arm64 + runs-on: ubuntu-24.04-arm + timeout-minutes: 15 + steps: + - name: Verify native Linux architecture + run: | + set -euo pipefail + machine="$(uname -m)" + printf 'uname -m=%s\n' "$machine" + test "$machine" = "aarch64" + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 26.11.1 + package-manager-cache: false + + - name: Install verified npm + uses: ./.github/actions/setup-verified-npm + + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: true + + - name: Setup Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.15.0' + + - name: Run Linux filesystem and process invariants + run: go test ./internal/kernel/rootpath ./internal/kernel/repositorytransaction ./internal/kernel/processgroup ./internal/command/projectstatus -count=1 + + - name: Run platform smoke + run: | + set -euo pipefail + npm run npm:version + npm run platform:smoke + + - name: Build release wheel set + run: | + set -euo pipefail + go run ./internal/tools/packagebuild all + go run ./internal/tools/pythonpackage build + + - name: Verify Python 3.11.0 installed wheel + run: go run ./internal/tools/pythonpackage verify-minimum + browser-runtime: name: quality / browser runtime runs-on: ubuntu-24.04 @@ -324,6 +379,7 @@ jobs: - source-quality - platform-smoke - platform-smoke-intel + - platform-smoke-linux-arm64 steps: - name: Verify required quality results run: | @@ -332,4 +388,5 @@ jobs: test "${{ needs.source-quality.result }}" = "success" test "${{ needs.platform-smoke.result }}" = "success" test "${{ needs.platform-smoke-intel.result }}" = "success" + test "${{ needs.platform-smoke-linux-arm64.result }}" = "success" printf 'OK: all required quality checks passed\n' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8245077f..059450b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -115,12 +115,12 @@ SZOMB. A truncated record or a permission error remains an ordinary observation failure: the observer runs only before the sole reap, so the unreaped child still pins its PID and group and retained cleanup may signal them. Linux uses fresh `waitid(P_PID, WEXITED|WNOWAIT|WNOHANG)` records. Source-check targets -are the configured Ubuntu 24.04, macos-15 (arm64) and macos-15-intel (x86_64) -jobs and the native Linux wheel verifier profile, each requiring actual -host/API qualification. A CI label, cross-build or one local run is not native -qualification of another host. macOS 13 and 14 are not executed: no hosted -macOS 13 image exists, and GitHub retires `macos-14` on 2026-11-02 after failing -brownouts (actions/runner-images#13518). +are the configured Ubuntu 24.04 (x86_64), ubuntu-24.04-arm (arm64), macos-15 +(arm64) and macos-15-intel (x86_64) jobs and the native Linux wheel verifier +profile, each requiring actual host/API qualification. A CI label, cross-build +or one local run is not native qualification of another host. macOS 13 and 14 +are not executed: no hosted macOS 13 image exists, and GitHub retires +`macos-14` on 2026-11-02 after failing brownouts (actions/runner-images#13518). This package is outside the CLI dependency closure on all four shipped targets; consumer wheel tags and platform minima are unchanged. Import or payload changes require a fresh closure proof, and unsupported hosts fail before child Start. diff --git a/README.md b/README.md index 0eca95d6..431e9cd8 100644 --- a/README.md +++ b/README.md @@ -149,15 +149,17 @@ Supported binary targets are macOS 15 or later on arm64 or x64, executed by required CI on the GitHub-hosted `macos-15` and `macos-15-intel` runners. Darwin binaries declare a macOS 13.0 minimum in their Mach-O build metadata; macOS 13 and 14 are binary-compatible by that metadata only, not executed or verified. -Linux manylinux 2.17 or later is supported on arm64 or x64. Windows is unsupported. The Python -runner requires Python 3.11 or later and wraps the same Go CLI; it is not a -Python SDK. +Linux manylinux 2.17 or later is supported on arm64 or x64, executed by +required CI on the GitHub-hosted `ubuntu-24.04-arm` and `ubuntu-24.04` runners. +The manylinux 2.17 floor is the wheel-tag declaration; CI does not execute a +host at that floor. Windows is unsupported. The Python runner requires Python +3.11 or later and wraps the same Go CLI; it is not a Python SDK. Linux wheel installation requires pip 20.3 or later for the manylinux tag. Use an installer compatible with the selected Python. The bounded compatibility -smoke exercises the initial Python 3.11 release with a checksum-pinned compatible -installer. This is compatibility evidence, not a recommendation to use an -obsolete interpreter. +smoke exercises the initial Python 3.11 release on Linux arm64 and x64 with a +checksum-pinned compatible installer. This is compatibility evidence, not a +recommendation to use an obsolete interpreter. After an exact Python package version is available from an admitted channel, use one complete package-manager chain: diff --git a/docs/specs/proofkit-supply-chain-quality/overview.md b/docs/specs/proofkit-supply-chain-quality/overview.md index 831b31f3..9b39c2a3 100644 --- a/docs/specs/proofkit-supply-chain-quality/overview.md +++ b/docs/specs/proofkit-supply-chain-quality/overview.md @@ -73,18 +73,21 @@ vulnerability absence, or consumer rollout safety by itself. tests does not prove assertion-branch execution, mutation adequacy, or exhaustive command semantics. - `REQ-PROOFKIT-QUALITY-011`: CI separates the OS-independent full - source/package gate from macOS platform smoke, executes the complete Go - package set through its owner command, uses explicit hosted runner labels - instead of floating latest labels, and exposes one fail-closed aggregate gate - whose exact expression and script reject neutralization and require every - required leaf check to finish with success, while the aggregate job omits - job defaults and `continue-on-error` entirely. Exact workflow-level bash - defaults plus a closed inventory require exact provider-check names and - scalar runners, bind each macOS job's (required arm64 `macos-15` and x86_64 - `macos-15-intel`) presence-aware exact ordered step inventory and exact - local setup-action bytes to the fail-closed package-script owner command, - require every required leaf job and step to - omit conditions except for exact named owner conditions, and reject + source/package gate from macOS and Linux arm64 platform smoke, executes the + complete Go package set through its owner command, uses explicit hosted + runner labels instead of floating latest labels, and exposes one fail-closed + aggregate gate whose exact expression and script reject neutralization and + require every required leaf check to finish with success, while the + aggregate job omits job defaults and `continue-on-error` entirely. Exact + workflow-level bash defaults plus a closed inventory require exact + provider-check names and scalar runners, bind each platform job's (required + arm64 `macos-15`, x86_64 `macos-15-intel` and aarch64 `ubuntu-24.04-arm`) + presence-aware exact ordered step inventory, whose first step checks the + native architecture, and exact local setup-action bytes to the fail-closed + package-script owner command, require the minimum-version CPython installed-wheel + smoke in both the Linux x86_64 source job and the Linux arm64 job, require + every required leaf job and step to omit conditions except for exact named + owner conditions, and reject dynamically false or explicit-null conditions, dual `run`/`uses` keys, non-string runner labels, nested or top-level semantic shadow steps, reusable-job substitution, inherited or local environment entries, shell, diff --git a/docs/specs/proofkit-supply-chain-quality/requirements.v2.json b/docs/specs/proofkit-supply-chain-quality/requirements.v2.json index a49c6300..5a455c75 100644 --- a/docs/specs/proofkit-supply-chain-quality/requirements.v2.json +++ b/docs/specs/proofkit-supply-chain-quality/requirements.v2.json @@ -21,7 +21,7 @@ , {"requirementId":"REQ-PROOFKIT-QUALITY-008","statementCompletion":"GitHub Actions workflow syntax and expression semantics are checked by actionlint in the local package gate.","fields":{"nonClaims":["This requirement does not claim workflow runtime success, external action safety, branch protection, or release approval."],"externalNonClaimRefs":["NC-PROOFKIT-QUALITY-008"],"proofBindingRefs":["proofkit/requirement-bindings.json"],"ownerId":"proofkit.supply-chain-quality","claimLevel":"blocking","riskClass":"medium","nonClaimRefs":[],"lifecycle":{"state":"active"},"deferral":null,"updatePolicy":{"reviewOwnerId":"proofkit.supply-chain-quality","requiresImpactDeclaration":true,"requiresProofBindingReview":true}}} , {"requirementId":"REQ-PROOFKIT-QUALITY-009","statementCompletion":"Performance-sensitive parser and serializer paths expose benchmark entrypoints without making wall-clock budgets a required PR gate before stable baselines exist.","fields":{"nonClaims":["This requirement does not claim current performance maximum, CI wall-clock budget enforcement, or regression absence without benchmark evidence."],"externalNonClaimRefs":["NC-PROOFKIT-QUALITY-009"],"proofBindingRefs":["proofkit/requirement-bindings.json"],"ownerId":"proofkit.supply-chain-quality","claimLevel":"blocking","riskClass":"medium","nonClaimRefs":[],"lifecycle":{"state":"active"},"deferral":null,"updatePolicy":{"reviewOwnerId":"proofkit.supply-chain-quality","requiresImpactDeclaration":true,"requiresProofBindingReview":true}}} , {"requirementId":"REQ-PROOFKIT-QUALITY-010","statementCompletion":"Coverage metrics report requirement, binding, witness, CLI inventory linkage, descriptor-owned command proof-route candidates, and a separate execution-backed command-oracle ledger. The ledger must bind every candidate commandRef, selector, concrete negative case, falsification event, assertion oracle, expected public outcome, and owner invariant to one immutable exact-file materialized source snapshot; execute exactly the selected active Go tests through canonically ordered package-scoped argv vectors that cannot select same-named non-candidate tests in another package; require unique package start and pass events, unique test run and pass events, owner-reserved cooperative attributes for every candidate, bounded cancellable subprocesses, and immediate process-group termination on output-limit violation; reject missing, unknown, duplicate, skipped, failed, reordered, context-invalid, spoofed-output, cross-test, source-drift, producer-unreachable candidate projections, and correlated identity substitutions; publish and invalidate local diagnostic and metrics artifacts atomically through repository-confined non-symlink destinations; on supported release platforms, open retained input artifacts descriptor-first through a nonblocking no-follow boundary before type, identity, and exact byte-limit checks so named pipes, symlinks, path substitution, and post-read mutation cannot block or escape, and fail closed instead of using a weaker file-open path elsewhere; revalidate current candidate, corpus, runtime, and source identities before and after artifact publication; and fail closed when any public command lacks execution-backed evidence. An independently authored versioned counterfeit corpus must bijectively cover every shipped evidence class, required policy axis, record coordinate, and declared substitution axis with positive controls and checked-in exact expected decisions evaluated through the production admission and lifecycle owners rather than a generated expectation registry. Static route metadata, prose, legacy source markers, test existence, and failure-capable syntax remain proof-route candidates and cannot satisfy the execution-backed closure.","fields":{"nonClaims":["Successful selected tests do not prove assertion-branch execution, mutation adequacy, line coverage, dead-code absence, exhaustive command semantics, malicious-test resistance, producer authentication, receipt freshness, merge satisfaction, or production readiness."],"externalNonClaimRefs":["NC-PROOFKIT-QUALITY-010"],"proofBindingRefs":["proofkit/requirement-bindings.json"],"ownerId":"proofkit.supply-chain-quality","claimLevel":"blocking","riskClass":"medium","nonClaimRefs":[],"lifecycle":{"state":"active"},"deferral":null,"updatePolicy":{"reviewOwnerId":"proofkit.supply-chain-quality","requiresImpactDeclaration":true,"requiresProofBindingReview":true}}} - , {"requirementId":"REQ-PROOFKIT-QUALITY-011","statementCompletion":"CI separates the full source/package gate with an explicit native Linux Python 3.11.0 installed-wheel subcheck from the platform-specific macOS execution smoke, runs the complete processgroup package on each macOS lane without selector-based narrowing alongside the existing filesystem checks, executes the complete Go package set through the owner command rather than a partial package list, requires the local composed Go gate and CI source job to run non-mutating go mod tidy -diff followed by go mod verify before Go tests with either failure stopping the chain, requires exact owner-reviewed root check and go:check AND-only command chains, forbids pre/post npm lifecycle script keys by presence for check and every direct npm-run target in those chains or CI source steps without redefining unrelated npm scripts, uses exact scalar hosted runners and provider-check names, requires each macOS job's presence-aware exact ordered step inventory, with distinct required macos-15 arm64 and macos-15-intel x86_64 jobs, and exact repository-local setup-action bytes to execute the exact fail-closed platform-smoke package-script owner command, and exposes one exact fail-closed aggregate quality gate under exact bash workflow run defaults with no inherited working-directory or environment entries; a closed inventory requires every required leaf job and step to omit conditions except for exact owner-reviewed named conditions, every required leaf job and the aggregate job omit job defaults, reusable-workflow substitution, and continue-on-error and have no job environment entries, all of their steps omit shell, working-directory, and continue-on-error and have no step environment entries, and the aggregate's reviewed expression and script require every exact required leaf check to finish with success; workflow mutation fixtures preserve nil versus empty values, field-presence bits and detached mutable data before applying negative changes; neutralizing wrappers, dynamically false or explicit-null conditions, execution overrides, dual run/uses keys, extra disjunctions, non-string runner labels, nested or top-level semantic shadow steps, no-op or reusable-job substitution, skipped, failed, or cancelled leaf jobs cannot satisfy branch-protection evidence. The unconditional minimum-Python owner step immediately follows package artifact verification and precedes receipts and closeout; omission, neutralization or failure cannot yield source-quality success, and the same four required leaf jobs remain in the aggregate.","fields":{"nonClaims":["This requirement does not claim provider-run success, branch-protection settings, Windows support, performance budget enforcement, or release approval."],"externalNonClaimRefs":["NC-PROOFKIT-QUALITY-011"],"proofBindingRefs":["proofkit/requirement-bindings.json"],"ownerId":"proofkit.supply-chain-quality","claimLevel":"blocking","riskClass":"medium","nonClaimRefs":[],"lifecycle":{"state":"active"},"deferral":null,"updatePolicy":{"reviewOwnerId":"proofkit.supply-chain-quality","requiresImpactDeclaration":true,"requiresProofBindingReview":true}}} + , {"requirementId":"REQ-PROOFKIT-QUALITY-011","statementCompletion":"CI separates the full source/package gate with an explicit native Linux x86_64 Python 3.11.0 installed-wheel subcheck from the platform-specific macOS and Linux arm64 execution smoke, runs the complete processgroup package on each macOS and Linux arm64 platform lane without selector-based narrowing alongside the existing filesystem checks, executes the complete Go package set through the owner command rather than a partial package list, requires the local composed Go gate and CI source job to run non-mutating go mod tidy -diff followed by go mod verify before Go tests with either failure stopping the chain, requires exact owner-reviewed root check and go:check AND-only command chains, forbids pre/post npm lifecycle script keys by presence for check and every direct npm-run target in those chains or CI source steps without redefining unrelated npm scripts, uses exact scalar hosted runners and provider-check names, requires each platform job's presence-aware exact ordered step inventory, with distinct required macos-15 arm64, macos-15-intel x86_64 and ubuntu-24.04-arm aarch64 jobs whose first step fails unless uname -m reports the job's native architecture, and exact repository-local setup-action bytes to execute the exact fail-closed platform-smoke package-script owner command, and exposes one exact fail-closed aggregate quality gate under exact bash workflow run defaults with no inherited working-directory or environment entries; a closed inventory requires every required leaf job and step to omit conditions except for exact owner-reviewed named conditions, every required leaf job and the aggregate job omit job defaults, reusable-workflow substitution, and continue-on-error and have no job environment entries, all of their steps omit shell, working-directory, and continue-on-error and have no step environment entries, and the aggregate's reviewed expression and script require every exact required leaf check to finish with success; workflow mutation fixtures preserve nil versus empty values, field-presence bits and detached mutable data before applying negative changes; neutralizing wrappers, dynamically false or explicit-null conditions, execution overrides, dual run/uses keys, extra disjunctions, non-string runner labels, nested or top-level semantic shadow steps, no-op or reusable-job substitution, skipped, failed, or cancelled leaf jobs cannot satisfy branch-protection evidence. The unconditional minimum-Python owner step immediately follows package artifact verification in the source job and precedes receipts and closeout, and in the Linux arm64 job it immediately follows a build of the complete current release wheel set; omission or neutralization of either step cannot yield source-quality success, failure of either step cannot yield success of the job that runs it, and none of them can yield required-aggregate success; exactly five required leaf jobs remain in the aggregate.","fields":{"nonClaims":["This requirement does not claim provider-run success, branch-protection settings, Windows support, performance budget enforcement, or release approval."],"externalNonClaimRefs":["NC-PROOFKIT-QUALITY-011"],"proofBindingRefs":["proofkit/requirement-bindings.json"],"ownerId":"proofkit.supply-chain-quality","claimLevel":"blocking","riskClass":"medium","nonClaimRefs":[],"lifecycle":{"state":"active"},"deferral":null,"updatePolicy":{"reviewOwnerId":"proofkit.supply-chain-quality","requiresImpactDeclaration":true,"requiresProofBindingReview":true}}} , {"requirementId":"REQ-PROOFKIT-QUALITY-012","statementCompletion":"Release and adoption channel identifiers are admitted by one canonical authority vocabulary that separates authorityChannel values from display labels, publisher environments, statuses, registry sub-vocabulary, and candidate evidence classes.","fields":{"nonClaims":["This requirement does not claim provider-run success, registry publication, GitHub Release creation, consumer adoption, rollout approval, or release approval."],"externalNonClaimRefs":["NC-PROOFKIT-QUALITY-012"],"proofBindingRefs":["proofkit/requirement-bindings.json"],"ownerId":"proofkit.supply-chain-quality","claimLevel":"blocking","riskClass":"medium","nonClaimRefs":[],"lifecycle":{"state":"active"},"deferral":null,"updatePolicy":{"reviewOwnerId":"proofkit.supply-chain-quality","requiresImpactDeclaration":true,"requiresProofBindingReview":true}}} , {"requirementId":"REQ-PROOFKIT-QUALITY-013","statementCompletion":"Self-hosting workflow package-gate evidence is checked by a typed workflow oracle that proves the package gate is reachable, exact, advisory, and guarded only by an absent condition or the exact owner-reviewed condition for every trust-significant job and step; the oracle checks the complete required CI and release job inventories, rejects neutralizing or dynamically false expression variants, requires any workflow run defaults to retain exact bash without a working-directory override, admits only the exact owner-reviewed workflow and step environment entries, requires the gate job to omit job defaults and continue-on-error and have no job environment entries, requires every step in that job to omit shell, working-directory, and continue-on-error, and orders the gate before evidence publication instead of relying on workflow text search. The CI source job includes the exact minimum-Python installed-wheel invocation after package artifacts and before self-hosting receipts, coverage and closeout; its execution requirement does not promote local advisory receipts into authenticated provider evidence.","fields":{"nonClaims":["This requirement does not claim full GitHub Actions interpretation, provider-run success, branch-protection enforcement, required-check enforcement, external action safety, registry publication, release approval, or rollout approval."],"externalNonClaimRefs":["NC-PROOFKIT-QUALITY-013"],"proofBindingRefs":["proofkit/requirement-bindings.json"],"ownerId":"proofkit.supply-chain-quality","claimLevel":"blocking","riskClass":"medium","nonClaimRefs":[],"lifecycle":{"state":"active"},"deferral":null,"updatePolicy":{"reviewOwnerId":"proofkit.supply-chain-quality","requiresImpactDeclaration":true,"requiresProofBindingReview":true}}} , {"requirementId":"REQ-PROOFKIT-QUALITY-014","statementCompletion":"Release authority consumers compare downstream policy against the admitted typed release-authority projection and admitted report digest instead of rereading caller-owned raw releaseAuthorityInput fields after validation.","fields":{"nonClaims":["This requirement does not claim public SDK stability, registry publication, package installation, consumer dependency policy, rollout approval, merge approval, provider provenance, proof freshness, or native witness execution."],"externalNonClaimRefs":["NC-PROOFKIT-QUALITY-014"],"proofBindingRefs":["proofkit/requirement-bindings.json"],"ownerId":"proofkit.supply-chain-quality","claimLevel":"blocking","riskClass":"high","nonClaimRefs":[],"lifecycle":{"state":"active"},"deferral":null,"updatePolicy":{"reviewOwnerId":"proofkit.supply-chain-quality","requiresImpactDeclaration":true,"requiresProofBindingReview":true}}} diff --git a/internal/tools/pythonpackage/metadata_test.go b/internal/tools/pythonpackage/metadata_test.go index 5a3f6ddb..bd36e638 100644 --- a/internal/tools/pythonpackage/metadata_test.go +++ b/internal/tools/pythonpackage/metadata_test.go @@ -181,20 +181,29 @@ func TestREADMEPlatformAndPythonProjection(t *testing.T) { if ownerMinimums["darwin"] != "13" { t.Fatalf("Darwin wheel-tag minimum %s no longer matches the README executed/metadata-only split", ownerMinimums["darwin"]) } + // Each OS names its required CI runners in the owner architecture order; + // a changed architecture set requires re-authoring the runner lists. + for _, goos := range []string{"darwin", "linux"} { + if strings.Join(ownerArchitectures[goos], " ") != "arm64 x64" { + t.Fatalf("%s architectures %v no longer match the README runner lists", goos, ownerArchitectures[goos]) + } + } expectedBlock := strings.Join([]string{ fmt.Sprintf("Supported binary targets are macOS 15 or later on %s, executed by", strings.Join(ownerArchitectures["darwin"], " or ")), "required CI on the GitHub-hosted `macos-15` and `macos-15-intel` runners. Darwin", "binaries declare a macOS 13.0 minimum in their Mach-O build metadata; macOS 13", "and 14 are binary-compatible by that metadata only, not executed or verified.", - fmt.Sprintf("Linux manylinux %s or later is supported on %s. Windows is unsupported. The Python", ownerMinimums["linux"], strings.Join(ownerArchitectures["linux"], " or ")), - "runner requires Python 3.11 or later and wraps the same Go CLI; it is not a", - "Python SDK.", + fmt.Sprintf("Linux manylinux %s or later is supported on %s, executed by", ownerMinimums["linux"], strings.Join(ownerArchitectures["linux"], " or ")), + "required CI on the GitHub-hosted `ubuntu-24.04-arm` and `ubuntu-24.04` runners.", + fmt.Sprintf("The manylinux %s floor is the wheel-tag declaration; CI does not execute a", ownerMinimums["linux"]), + "host at that floor. Windows is unsupported. The Python runner requires Python", + "3.11 or later and wraps the same Go CLI; it is not a Python SDK.", "", "Linux wheel installation requires pip 20.3 or later for the manylinux tag.", "Use an installer compatible with the selected Python. The bounded compatibility", - "smoke exercises the initial Python 3.11 release with a checksum-pinned compatible", - "installer. This is compatibility evidence, not a recommendation to use an", - "obsolete interpreter.", + "smoke exercises the initial Python 3.11 release on Linux arm64 and x64 with a", + "checksum-pinned compatible installer. This is compatibility evidence, not a", + "recommendation to use an obsolete interpreter.", "", "After an exact Python package version is available from an admitted channel,", "use one complete package-manager chain:", diff --git a/scripts/workflow_package_gate_oracle_test.go b/scripts/workflow_package_gate_oracle_test.go index 8f7b5331..a1da8a7e 100644 --- a/scripts/workflow_package_gate_oracle_test.go +++ b/scripts/workflow_package_gate_oracle_test.go @@ -669,6 +669,25 @@ func needsList(raw any) []string { } } +// withoutNeed removes exactly one owner need so a mutant omits only that job. +func withoutNeed(t *testing.T, raw any, job string) []any { + t.Helper() + values, ok := raw.([]any) + if !ok { + t.Fatalf("owner needs=%#v, want a list", raw) + } + result := make([]any, 0, len(values)) + for _, value := range values { + if value != job { + result = append(result, value) + } + } + if len(result) != len(values)-1 { + t.Fatalf("owner needs=%#v must contain %q exactly once", raw, job) + } + return result +} + func nestedValue(raw any, path []string) any { value := raw for _, key := range path { @@ -734,7 +753,7 @@ func TestCIWorkflowDeclaresFailClosedRequiredAggregate(t *testing.T) { if !usesAlwaysStatusCheck(gate.If) { t.Fatalf("ci-required-gate if=%q, want always() so failed or skipped needs are inspected", gate.If) } - wantNeeds := []string{"browser-runtime", "platform-smoke", "platform-smoke-intel", "source-quality"} + wantNeeds := []string{"browser-runtime", "platform-smoke", "platform-smoke-intel", "platform-smoke-linux-arm64", "source-quality"} if got := needsList(gate.Needs); !reflect.DeepEqual(got, wantNeeds) { t.Fatalf("ci-required-gate needs=%#v, want %#v", got, wantNeeds) } @@ -906,6 +925,34 @@ func TestCIRequiredAggregateRejectsExecutionOverrides(t *testing.T) { workflow.Jobs["platform-smoke-intel"] = job }, }, + { + name: "required Linux arm64 leaf job continue on error", + mutate: func(workflow *githubWorkflow) { + job := workflow.Jobs["platform-smoke-linux-arm64"] + job.ContinueOnError = "${{ 1 == 1 }}" + workflow.Jobs["platform-smoke-linux-arm64"] = job + }, + }, + { + name: "required Linux arm64 leaf job environment", + mutate: func(workflow *githubWorkflow) { + job := workflow.Jobs["platform-smoke-linux-arm64"] + job.Env = map[string]any{"GOARCH": "amd64"} + workflow.Jobs["platform-smoke-linux-arm64"] = job + }, + }, + { + name: "required Linux arm64 leaf step environment", + mutate: func(workflow *githubWorkflow) { + job := workflow.Jobs["platform-smoke-linux-arm64"] + for index := range job.Steps { + if job.Steps[index].Name == "Verify Python 3.11.0 installed wheel" { + job.Steps[index].Env = map[string]any{"DOCKER_DEFAULT_PLATFORM": "linux/amd64"} + } + } + workflow.Jobs["platform-smoke-linux-arm64"] = job + }, + }, { name: "required leaf job null continue on error", mutate: func(workflow *githubWorkflow) { @@ -1348,7 +1395,7 @@ func TestCIRequiredAggregateRejectsPlatformSmokeSubstitution(t *testing.T) { }, "x86_64 job dropped from aggregate needs": func(workflow *githubWorkflow) { gate := workflow.Jobs["ci-required-gate"] - gate.Needs = []any{"browser-runtime", "source-quality", "platform-smoke"} + gate.Needs = withoutNeed(t, gate.Needs, "platform-smoke-intel") workflow.Jobs["ci-required-gate"] = gate }, "x86_64 result unchecked by aggregate": func(workflow *githubWorkflow) { @@ -1359,7 +1406,7 @@ func TestCIRequiredAggregateRejectsPlatformSmokeSubstitution(t *testing.T) { "x86_64 job removed consistently": func(workflow *githubWorkflow) { delete(workflow.Jobs, "platform-smoke-intel") gate := workflow.Jobs["ci-required-gate"] - gate.Needs = []any{"browser-runtime", "source-quality", "platform-smoke"} + gate.Needs = withoutNeed(t, gate.Needs, "platform-smoke-intel") gate.Steps[0].Run = strings.Replace(gate.Steps[0].Run, intelAggregateLine, "", 1) workflow.Jobs["ci-required-gate"] = gate }, @@ -1375,6 +1422,198 @@ func TestCIRequiredAggregateRejectsPlatformSmokeSubstitution(t *testing.T) { } }) } + // The Linux arm64 claim depends on its own required native ARM runner: the + // job must prove aarch64 in-job before checkout, run the owner invariant, + // smoke and CPython 3.11.0 steps exactly, and stay in the aggregate. + linuxJob := linuxArm64PlatformSmokeJob + linuxStepIndex := func(job githubJob, name string) int { + index, err := uniqueStepIndex(job.Steps, name) + if err != nil || index < 0 { + t.Fatalf("locate %s step %q: index=%d err=%v", linuxJob, name, index, err) + } + return index + } + linuxArchitectureLine := "test \"$machine\" = \"aarch64\"\n" + linuxAggregateLine := "test \"${{ needs.platform-smoke-linux-arm64.result }}\" = \"success\"\n" + if job := base.Jobs[linuxJob]; !strings.Contains(job.Steps[linuxStepIndex(job, "Verify native Linux architecture")].Run, linuxArchitectureLine) { + t.Fatal("owner Linux arm64 job lacks the aarch64 assertion line") + } + if !strings.Contains(base.Jobs["ci-required-gate"].Steps[0].Run, linuxAggregateLine) { + t.Fatal("owner aggregate lacks the Linux arm64 result line") + } + linuxCases := []struct { + name string + mutate func(*githubWorkflow) + }{ + {"x64 runner", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + job.RunsOn = "ubuntu-24.04" + workflow.Jobs[linuxJob] = job + }}, + {"Darwin arm64 runner", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + job.RunsOn = "macos-15" + workflow.Jobs[linuxJob] = job + }}, + {"older ARM runner image", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + job.RunsOn = "ubuntu-22.04-arm" + workflow.Jobs[linuxJob] = job + }}, + {"non-string runner label", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + job.RunsOn = []any{"ubuntu-24.04-arm", false} + workflow.Jobs[linuxJob] = job + }}, + {"timeout lowered", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + job.TimeoutMinutes = 1 + workflow.Jobs[linuxJob] = job + }}, + {"provider check name drift", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + job.Name = "quality / platform smoke" + workflow.Jobs[linuxJob] = job + }}, + {"architecture check omitted", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + index := linuxStepIndex(job, "Verify native Linux architecture") + job.Steps = append(job.Steps[:index], job.Steps[index+1:]...) + workflow.Jobs[linuxJob] = job + }}, + {"architecture assertion removed", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + index := linuxStepIndex(job, "Verify native Linux architecture") + job.Steps[index].Run = strings.Replace(job.Steps[index].Run, linuxArchitectureLine, "", 1) + workflow.Jobs[linuxJob] = job + }}, + {"architecture assertion expects x86_64", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + index := linuxStepIndex(job, "Verify native Linux architecture") + job.Steps[index].Run = strings.Replace(job.Steps[index].Run, linuxArchitectureLine, "test \"$machine\" = \"x86_64\"\n", 1) + workflow.Jobs[linuxJob] = job + }}, + {"architecture assertion masked", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + index := linuxStepIndex(job, "Verify native Linux architecture") + job.Steps[index].Run = strings.Replace(job.Steps[index].Run, linuxArchitectureLine, "test \"$machine\" = \"aarch64\" || true\n", 1) + workflow.Jobs[linuxJob] = job + }}, + {"architecture check moved after checkout", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + index := linuxStepIndex(job, "Verify native Linux architecture") + checkout := linuxStepIndex(job, "Checkout") + job.Steps[index], job.Steps[checkout] = job.Steps[checkout], job.Steps[index] + workflow.Jobs[linuxJob] = job + }}, + {"Darwin architecture check substituted", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + index := linuxStepIndex(job, "Verify native Linux architecture") + job.Steps[index] = workflow.Jobs["platform-smoke"].Steps[0] + workflow.Jobs[linuxJob] = job + }}, + {"process package omitted", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + for i := range job.Steps { + job.Steps[i].Run = strings.ReplaceAll(job.Steps[i].Run, " ./internal/kernel/processgroup", "") + } + workflow.Jobs[linuxJob] = job + }}, + {"process suite narrowed", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + index := linuxStepIndex(job, "Run Linux filesystem and process invariants") + job.Steps[index].Run += " -run '^TestLinuxObservationClassification$'" + workflow.Jobs[linuxJob] = job + }}, + {"invariants omitted", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + index := linuxStepIndex(job, "Run Linux filesystem and process invariants") + job.Steps = append(job.Steps[:index], job.Steps[index+1:]...) + workflow.Jobs[linuxJob] = job + }}, + {"platform smoke neutralized", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + job.Steps[linuxStepIndex(job, "Run platform smoke")].Run = "true" + workflow.Jobs[linuxJob] = job + }}, + {"release wheel set narrowed to current", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + index := linuxStepIndex(job, "Build release wheel set") + job.Steps[index].Run = strings.Replace(job.Steps[index].Run, "packagebuild all", "packagebuild current", 1) + workflow.Jobs[linuxJob] = job + }}, + {"CPython 3.11.0 smoke omitted", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + index := linuxStepIndex(job, "Verify Python 3.11.0 installed wheel") + job.Steps = append(job.Steps[:index], job.Steps[index+1:]...) + workflow.Jobs[linuxJob] = job + }}, + {"no-op replaces job steps", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + job.Steps = []githubStep{{Name: "No-op", Run: "true", runPresent: true}} + workflow.Jobs[linuxJob] = job + }}, + {"Darwin step list substituted", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + job.Steps = workflow.Jobs["platform-smoke"].Steps + workflow.Jobs[linuxJob] = job + }}, + {"reusable job substitution", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + job.Steps = nil + job.Uses = "example/ci/.github/workflows/no-op.yml@0123456789012345678901234567890123456789" + workflow.Jobs[linuxJob] = job + }}, + {"inserted semantic shadow step", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + job.Steps = append([]githubStep{{Name: "Shadow platform smoke", Run: "npm pkg set scripts.platform:smoke=true", runPresent: true}}, job.Steps...) + workflow.Jobs[linuxJob] = job + }}, + {"job continue on error", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + job.ContinueOnError = true + workflow.Jobs[linuxJob] = job + }}, + {"job condition", func(workflow *githubWorkflow) { + job := workflow.Jobs[linuxJob] + job.If = "${{ github.event_name == 'never' }}" + workflow.Jobs[linuxJob] = job + }}, + {"dropped from aggregate needs", func(workflow *githubWorkflow) { + gate := workflow.Jobs["ci-required-gate"] + gate.Needs = withoutNeed(t, gate.Needs, linuxJob) + workflow.Jobs["ci-required-gate"] = gate + }}, + {"result unchecked by aggregate", func(workflow *githubWorkflow) { + gate := workflow.Jobs["ci-required-gate"] + gate.Steps[0].Run = strings.Replace(gate.Steps[0].Run, linuxAggregateLine, "", 1) + workflow.Jobs["ci-required-gate"] = gate + }}, + {"result masked by aggregate", func(workflow *githubWorkflow) { + gate := workflow.Jobs["ci-required-gate"] + gate.Steps[0].Run = strings.Replace(gate.Steps[0].Run, linuxAggregateLine, strings.TrimSuffix(linuxAggregateLine, "\n")+" || true\n", 1) + workflow.Jobs["ci-required-gate"] = gate + }}, + {"removed consistently", func(workflow *githubWorkflow) { + delete(workflow.Jobs, linuxJob) + gate := workflow.Jobs["ci-required-gate"] + gate.Needs = withoutNeed(t, gate.Needs, linuxJob) + gate.Steps[0].Run = strings.Replace(gate.Steps[0].Run, linuxAggregateLine, "", 1) + workflow.Jobs["ci-required-gate"] = gate + }}, + } + for _, test := range linuxCases { + t.Run(linuxJob+"/"+test.name, func(t *testing.T) { + workflow := cloneWorkflow(t, base) + test.mutate(&workflow) + if reflect.DeepEqual(workflow, base) { + t.Fatal("Linux arm64 mutation did not change the owner workflow") + } + if err := validateCIRequiredAggregate(workflow); err == nil { + t.Fatal("Linux arm64 platform-smoke weakening was admitted") + } + }) + } t.Run("package script owner neutralized", func(t *testing.T) { var manifest map[string]any if err := json.Unmarshal(packageManifest, &manifest); err != nil { @@ -1614,6 +1853,7 @@ test "${{ needs.browser-runtime.result }}" = "success" test "${{ needs.source-quality.result }}" = "success" test "${{ needs.platform-smoke.result }}" = "success" test "${{ needs.platform-smoke-intel.result }}" = "success" +test "${{ needs.platform-smoke-linux-arm64.result }}" = "success" printf 'OK: all required quality checks passed\n'` const requiredPlatformSmokeShell = `set -euo pipefail @@ -1621,6 +1861,25 @@ npm run npm:version npm run platform:smoke ` +// The exact required leaf jobs of the CI aggregate, in needsList order. +var ciRequiredLeafJobs = []string{"browser-runtime", "platform-smoke", "platform-smoke-intel", "platform-smoke-linux-arm64", "source-quality"} + +const linuxArm64PlatformSmokeJob = "platform-smoke-linux-arm64" + +// The Linux arm64 job builds the complete current release wheel set so the +// CPython 3.11.0 installed smoke can select and rebuild its native wheel. +const requiredReleaseWheelSetShell = `set -euo pipefail +go run ./internal/tools/packagebuild all +go run ./internal/tools/pythonpackage build +` + +const requiredMinimumPythonCommand = "go run ./internal/tools/pythonpackage verify-minimum" + +// Every platform job runs the same filesystem and process packages natively; +// their Darwin or Linux build-tagged tests qualify that host for the source +// tools' process lifecycle. +const requiredPlatformInvariantCommand = "go test ./internal/kernel/rootpath ./internal/kernel/repositorytransaction ./internal/kernel/processgroup ./internal/command/projectstatus -count=1" + func readPackageManifestForTest(t *testing.T) []byte { t.Helper() raw, err := os.ReadFile(filepath.Join("..", "package.json")) @@ -1677,6 +1936,15 @@ var platformSmokeArchitectures = map[string]string{ "platform-smoke-intel": "x86_64", } +// The Linux arm64 job first proves in-job that the runner is natively +// aarch64, so the runner label is not the only evidence. The job declares no +// container, so its run steps execute on the runner host. +const requiredLinuxArm64ArchitectureShell = `set -euo pipefail +machine="$(uname -m)" +printf 'uname -m=%s\n' "$machine" +test "$machine" = "aarch64" +` + func requiredDarwinArchitectureShell(architecture string) string { return `set -euo pipefail machine="$(uname -m)" @@ -1689,17 +1957,70 @@ test "${translated:-0}" = "0" // Both Darwin architectures run the same owner step list. Only the first // step's expected architecture and the job topology (name, runner label, -// timeout) distinguish arm64 from x86_64. +// timeout) distinguish arm64 from x86_64. The Linux arm64 job runs the same +// setup, invariant packages and platform smoke after its own architecture +// check, then the CPython 3.11.0 installed smoke over a fresh release wheel set. func validateExactPlatformSmokeSteps(jobID string, job githubJob) error { - architecture, ok := platformSmokeArchitectures[jobID] - if !ok { - return fmt.Errorf("ci workflow %s has no owner Darwin architecture", jobID) - } - expected := []githubStep{ - { + var expected []githubStep + if jobID == linuxArm64PlatformSmokeJob { + expected = append(expected, githubStep{ + Name: "Verify native Linux architecture", + Run: requiredLinuxArm64ArchitectureShell, + }) + expected = append(expected, platformSmokeSetupSteps()...) + expected = append(expected, + githubStep{Name: "Run Linux filesystem and process invariants", Run: requiredPlatformInvariantCommand}, + githubStep{Name: "Run platform smoke", Run: requiredPlatformSmokeShell}, + githubStep{Name: "Build release wheel set", Run: requiredReleaseWheelSetShell}, + githubStep{Name: "Verify Python 3.11.0 installed wheel", Run: requiredMinimumPythonCommand}, + ) + } else { + architecture, ok := platformSmokeArchitectures[jobID] + if !ok { + return fmt.Errorf("ci workflow %s has no owner platform architecture", jobID) + } + expected = append(expected, githubStep{ Name: "Verify native Darwin architecture", Run: requiredDarwinArchitectureShell(architecture), - }, + }) + expected = append(expected, platformSmokeSetupSteps()...) + expected = append(expected, + githubStep{Name: "Run Darwin filesystem and process invariants", Run: requiredPlatformInvariantCommand}, + githubStep{Name: "Run platform smoke", Run: requiredPlatformSmokeShell}, + ) + } + if len(job.Steps) != len(expected) { + return fmt.Errorf( + "ci workflow %s step count=%d, want exact %d", + jobID, + len(job.Steps), + len(expected), + ) + } + for index, want := range expected { + actual := job.Steps[index] + if actual.Name != want.Name || + actual.Uses != want.Uses || + actual.Run != want.Run || + !reflect.DeepEqual(actual.With, want.With) || + actual.runPresent != (want.Run != "") || + actual.usesPresent != (want.Uses != "") || + actual.withPresent != (want.With != nil) { + return fmt.Errorf( + "ci workflow %s step %d differs from exact owner step %q", + jobID, + index, + want.Name, + ) + } + } + return nil +} + +// The pinned setup steps every platform job runs between its architecture +// check and its first repository command. +func platformSmokeSetupSteps() []githubStep { + return []githubStep{ { Name: "Checkout", Uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", @@ -1730,41 +2051,7 @@ func validateExactPlatformSmokeSteps(jobID string, job githubJob) error { Uses: "actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97", With: map[string]any{"python-version": "3.15.0"}, }, - { - Name: "Run Darwin filesystem and process invariants", - Run: "go test ./internal/kernel/rootpath ./internal/kernel/repositorytransaction ./internal/kernel/processgroup ./internal/command/projectstatus -count=1", - }, - { - Name: "Run platform smoke", - Run: requiredPlatformSmokeShell, - }, } - if len(job.Steps) != len(expected) { - return fmt.Errorf( - "ci workflow %s step count=%d, want exact %d", - jobID, - len(job.Steps), - len(expected), - ) - } - for index, want := range expected { - actual := job.Steps[index] - if actual.Name != want.Name || - actual.Uses != want.Uses || - actual.Run != want.Run || - !reflect.DeepEqual(actual.With, want.With) || - actual.runPresent != (want.Run != "") || - actual.usesPresent != (want.Uses != "") || - actual.withPresent != (want.With != nil) { - return fmt.Errorf( - "ci workflow %s step %d differs from exact owner step %q", - jobID, - index, - want.Name, - ) - } - } - return nil } func validateCIRequiredAggregate(workflow githubWorkflow) error { @@ -1790,7 +2077,7 @@ func validateCIRequiredAggregate(workflow githubWorkflow) error { if err := validateOptionalWorkflowRunDefaults(workflow.Defaults); err != nil { return fmt.Errorf("ci workflow run defaults: %w", err) } - wantNeeds := []string{"browser-runtime", "platform-smoke", "platform-smoke-intel", "source-quality"} + wantNeeds := ciRequiredLeafJobs for _, jobID := range append(append([]string{}, wantNeeds...), "ci-required-gate") { job, ok := workflow.Jobs[jobID] if !ok { @@ -1808,11 +2095,12 @@ func validateCIRequiredAggregate(workflow githubWorkflow) error { "ci workflow", workflow, map[string]string{ - "browser-runtime": "", - "platform-smoke": "", - "platform-smoke-intel": "", - "source-quality": "", - "ci-required-gate": "always()", + "browser-runtime": "", + "platform-smoke": "", + "platform-smoke-intel": "", + "platform-smoke-linux-arm64": "", + "source-quality": "", + "ci-required-gate": "always()", }, map[string]map[string]string{ "browser-runtime": { @@ -1827,11 +2115,12 @@ func validateCIRequiredAggregate(workflow githubWorkflow) error { runner string timeoutMinutes int }{ - "source-quality": {name: "quality / source", runner: "ubuntu-24.04", timeoutMinutes: 20}, - "platform-smoke": {name: "quality / platform smoke / macos-15", runner: "macos-15", timeoutMinutes: 12}, - "platform-smoke-intel": {name: "quality / platform smoke / macos-15-intel", runner: "macos-15-intel", timeoutMinutes: 20}, - "browser-runtime": {name: "quality / browser runtime", runner: "ubuntu-24.04", timeoutMinutes: 20}, - "ci-required-gate": {name: "quality / required aggregate", runner: "ubuntu-24.04", timeoutMinutes: 5}, + "source-quality": {name: "quality / source", runner: "ubuntu-24.04", timeoutMinutes: 20}, + "platform-smoke": {name: "quality / platform smoke / macos-15", runner: "macos-15", timeoutMinutes: 12}, + "platform-smoke-intel": {name: "quality / platform smoke / macos-15-intel", runner: "macos-15-intel", timeoutMinutes: 20}, + "platform-smoke-linux-arm64": {name: "quality / platform smoke / linux-arm64", runner: "ubuntu-24.04-arm", timeoutMinutes: 15}, + "browser-runtime": {name: "quality / browser runtime", runner: "ubuntu-24.04", timeoutMinutes: 20}, + "ci-required-gate": {name: "quality / required aggregate", runner: "ubuntu-24.04", timeoutMinutes: 5}, } for jobID, expected := range exactJobTopology { job := workflow.Jobs[jobID] @@ -1851,13 +2140,15 @@ func validateCIRequiredAggregate(workflow githubWorkflow) error { ) } } - for _, jobID := range []string{"platform-smoke", "platform-smoke-intel"} { + for _, jobID := range []string{"platform-smoke", "platform-smoke-intel", linuxArm64PlatformSmokeJob} { if err := validateExactPlatformSmokeSteps(jobID, workflow.Jobs[jobID]); err != nil { return err } } - if err := validateCIMinimumPythonStep(workflow.Jobs["source-quality"]); err != nil { - return err + for _, jobID := range ciMinimumPythonJobIDs() { + if err := validateCIMinimumPythonStep(jobID, workflow.Jobs[jobID]); err != nil { + return err + } } if err := validateCILegacyUnicodeStep(workflow.Jobs["source-quality"]); err != nil { return err diff --git a/scripts/workflow_python_minimum_test.go b/scripts/workflow_python_minimum_test.go index 4605d38d..017d8065 100644 --- a/scripts/workflow_python_minimum_test.go +++ b/scripts/workflow_python_minimum_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os/exec" "path/filepath" + "slices" "strings" "testing" ) @@ -13,49 +14,85 @@ func TestCIMinimumFailureCannotSatisfyAggregate(t *testing.T) { if err := validateCIRequiredAggregate(workflow); err != nil { t.Fatal(err) } - for _, status := range []string{"success", "failure", "skipped", "cancelled"} { - t.Run(status, func(t *testing.T) { - script := workflow.Jobs["ci-required-gate"].Steps[0].Run - for _, job := range []string{"source-quality", "browser-runtime", "platform-smoke", "platform-smoke-intel"} { - value := "success" - if job == "source-quality" { - value = status + // Both jobs that run the CPython 3.11.0 installed smoke are required + // leaves: a minimum failure in either job is a job result the aggregate + // must refuse, whatever the other leaves report. + leaves := []string{"source-quality", "browser-runtime", "platform-smoke", "platform-smoke-intel", "platform-smoke-linux-arm64"} + for _, failing := range ciMinimumPythonJobIDs() { + for _, status := range []string{"success", "failure", "skipped", "cancelled"} { + t.Run(failing+"/"+status, func(t *testing.T) { + script := workflow.Jobs["ci-required-gate"].Steps[0].Run + for _, job := range leaves { + value := "success" + if job == failing { + value = status + } + script = strings.ReplaceAll(script, "${{ needs."+job+".result }}", value) } - script = strings.ReplaceAll(script, "${{ needs."+job+".result }}", value) - } - command := exec.CommandContext(t.Context(), "bash", "-c", script) - output, err := command.CombinedOutput() - if (err == nil) != (status == "success") { - t.Fatalf("source-quality %s aggregate error=%v", status, err) - } - if status != "success" && strings.Contains(string(output), "OK:") { - t.Fatal("failed minimum lane emitted aggregate success") - } - }) + if strings.Contains(script, "${{") { + t.Fatalf("aggregate script references an unsubstituted job result: %q", script) + } + command := exec.CommandContext(t.Context(), "bash", "-c", script) + output, err := command.CombinedOutput() + if (err == nil) != (status == "success") { + t.Fatalf("%s %s aggregate error=%v", failing, status, err) + } + if status != "success" && strings.Contains(string(output), "OK:") { + t.Fatal("failed minimum lane emitted aggregate success") + } + }) + } + } +} + +// Each required job that runs the CPython 3.11.0 installed smoke names the +// step that produces its current release wheel set and the steps the smoke +// must precede. +var ciMinimumPythonJobs = map[string]struct { + producer string + successors []string +}{ + "source-quality": { + producer: "Build and verify package artifacts", + successors: []string{"Verify self-hosting receipts", "Verify self-hosting coverage", "Verify release closeout"}, + }, + linuxArm64PlatformSmokeJob: {producer: "Build release wheel set"}, +} + +func ciMinimumPythonJobIDs() []string { + ids := make([]string, 0, len(ciMinimumPythonJobs)) + for id := range ciMinimumPythonJobs { + ids = append(ids, id) } + slices.Sort(ids) + return ids } -func validateCIMinimumPythonStep(job githubJob) error { +func validateCIMinimumPythonStep(jobID string, job githubJob) error { + owner, ok := ciMinimumPythonJobs[jobID] + if !ok { + return fmt.Errorf("CI job %s has no owner Python minimum step", jobID) + } index, err := uniqueStepIndex(job.Steps, "Verify Python 3.11.0 installed wheel") if err != nil || index < 0 { - return fmt.Errorf("CI must contain exactly one Python minimum installed-wheel step") + return fmt.Errorf("CI job %s must contain exactly one Python minimum installed-wheel step", jobID) } step := job.Steps[index] - if step.Run != "go run ./internal/tools/pythonpackage verify-minimum" || !step.runPresent || + if step.Run != requiredMinimumPythonCommand || !step.runPresent || step.If != "" || step.ifPresent || step.ContinueOnError != nil || step.continueOnErrorPresent || step.Uses != "" || step.usesPresent || step.With != nil || step.withPresent || step.Env != nil || step.Shell != nil || step.shellPresent || step.WorkingDirectory != nil || step.workingDirectoryPresent || step.TimeoutMinutes != nil || step.timeoutMinutesPresent || step.ID != "" || step.idPresent { - return fmt.Errorf("CI Python minimum step must be the exact unconditional owner invocation") + return fmt.Errorf("CI job %s Python minimum step must be the exact unconditional owner invocation", jobID) } - buildIndex, err := uniqueStepIndex(job.Steps, "Build and verify package artifacts") + buildIndex, err := uniqueStepIndex(job.Steps, owner.producer) if err != nil || buildIndex < 0 || index != buildIndex+1 { - return fmt.Errorf("CI Python minimum step must immediately follow current package artifacts") + return fmt.Errorf("CI job %s Python minimum step must immediately follow %s", jobID, owner.producer) } - for _, successor := range []string{"Verify self-hosting receipts", "Verify self-hosting coverage", "Verify release closeout"} { + for _, successor := range owner.successors { next, err := uniqueStepIndex(job.Steps, successor) if err != nil || next <= index { - return fmt.Errorf("CI Python minimum step must precede %s", successor) + return fmt.Errorf("CI job %s Python minimum step must precede %s", jobID, successor) } } return nil @@ -63,48 +100,68 @@ func validateCIMinimumPythonStep(job githubJob) error { func TestCIMinimumPythonStepRejectsOmissionAndNeutralization(t *testing.T) { base := readWorkflowForTest(t, filepath.Join("..", ".github", "workflows", "ci.yml")) - if err := validateCIMinimumPythonStep(base.Jobs["source-quality"]); err != nil { - t.Fatal(err) + for _, jobID := range ciMinimumPythonJobIDs() { + if err := validateCIMinimumPythonStep(jobID, base.Jobs[jobID]); err != nil { + t.Fatal(err) + } } mutations := []struct { name string + jobs []string change func(*githubJob, int) }{ - {"omitted", func(job *githubJob, index int) { job.Steps = append(job.Steps[:index], job.Steps[index+1:]...) }}, - {"duplicate", func(job *githubJob, index int) { job.Steps = append(job.Steps, job.Steps[index]) }}, - {"before artifact", func(job *githubJob, index int) { + {"omitted", nil, func(job *githubJob, index int) { job.Steps = append(job.Steps[:index], job.Steps[index+1:]...) }}, + {"duplicate", nil, func(job *githubJob, index int) { job.Steps = append(job.Steps, job.Steps[index]) }}, + {"before artifact", nil, func(job *githubJob, index int) { job.Steps[index], job.Steps[index-1] = job.Steps[index-1], job.Steps[index] }}, - {"after receipts", func(job *githubJob, index int) { + {"after receipts", []string{"source-quality"}, func(job *githubJob, index int) { job.Steps[index], job.Steps[index+1] = job.Steps[index+1], job.Steps[index] }}, - {"no-op", func(job *githubJob, index int) { job.Steps[index].Run = "true" }}, - {"masked exit", func(job *githubJob, index int) { job.Steps[index].Run += " || true" }}, - {"background", func(job *githubJob, index int) { job.Steps[index].Run += " &" }}, - {"conditional", func(job *githubJob, index int) { job.Steps[index].If = "false" }}, - {"null condition", func(job *githubJob, index int) { job.Steps[index].ifPresent = true }}, - {"continue on error", func(job *githubJob, index int) { job.Steps[index].ContinueOnError = true }}, - {"null continue", func(job *githubJob, index int) { job.Steps[index].continueOnErrorPresent = true }}, - {"shadow environment", func(job *githubJob, index int) { job.Steps[index].Env = map[string]any{"PATH": "/other"} }}, - {"uses", func(job *githubJob, index int) { job.Steps[index].Uses = "other/action@main" }}, - {"wrong tool", func(job *githubJob, index int) { job.Steps[index].Run = "go run ./internal/tools/pythonpackage verify" }}, - } - for _, mutation := range mutations { - t.Run(mutation.name, func(t *testing.T) { - workflow := cloneWorkflow(t, base) - job := workflow.Jobs["source-quality"] - index, err := uniqueStepIndex(job.Steps, "Verify Python 3.11.0 installed wheel") - if err != nil { - t.Fatal(err) - } - mutation.change(&job, index) - workflow.Jobs["source-quality"] = job - if err := validateCIMinimumPythonStep(job); err == nil { - t.Fatal("minimum step mutation admitted by independent owner predicate") + {"before platform smoke", []string{linuxArm64PlatformSmokeJob}, func(job *githubJob, index int) { + step := job.Steps[index] + job.Steps = append(job.Steps[:index], job.Steps[index+1:]...) + smoke, err := uniqueStepIndex(job.Steps, "Run platform smoke") + if err != nil || smoke < 0 { + panic("owner Linux arm64 workflow lost Run platform smoke") } - if err := validateCIRequiredAggregate(workflow); err == nil { - t.Fatal("minimum step mutation admitted by aggregate oracle") + job.Steps = slices.Insert(job.Steps, smoke, step) + }}, + {"wheel set omitted", []string{linuxArm64PlatformSmokeJob}, func(job *githubJob, index int) { + job.Steps = append(job.Steps[:index-1], job.Steps[index:]...) + }}, + {"no-op", nil, func(job *githubJob, index int) { job.Steps[index].Run = "true" }}, + {"masked exit", nil, func(job *githubJob, index int) { job.Steps[index].Run += " || true" }}, + {"background", nil, func(job *githubJob, index int) { job.Steps[index].Run += " &" }}, + {"conditional", nil, func(job *githubJob, index int) { job.Steps[index].If = "false" }}, + {"null condition", nil, func(job *githubJob, index int) { job.Steps[index].ifPresent = true }}, + {"continue on error", nil, func(job *githubJob, index int) { job.Steps[index].ContinueOnError = true }}, + {"null continue", nil, func(job *githubJob, index int) { job.Steps[index].continueOnErrorPresent = true }}, + {"shadow environment", nil, func(job *githubJob, index int) { job.Steps[index].Env = map[string]any{"PATH": "/other"} }}, + {"uses", nil, func(job *githubJob, index int) { job.Steps[index].Uses = "other/action@main" }}, + {"wrong tool", nil, func(job *githubJob, index int) { job.Steps[index].Run = "go run ./internal/tools/pythonpackage verify" }}, + } + for _, jobID := range ciMinimumPythonJobIDs() { + for _, mutation := range mutations { + if mutation.jobs != nil && !slices.Contains(mutation.jobs, jobID) { + continue } - }) + t.Run(jobID+"/"+mutation.name, func(t *testing.T) { + workflow := cloneWorkflow(t, base) + job := workflow.Jobs[jobID] + index, err := uniqueStepIndex(job.Steps, "Verify Python 3.11.0 installed wheel") + if err != nil { + t.Fatal(err) + } + mutation.change(&job, index) + workflow.Jobs[jobID] = job + if err := validateCIMinimumPythonStep(jobID, job); err == nil { + t.Fatal("minimum step mutation admitted by independent owner predicate") + } + if err := validateCIRequiredAggregate(workflow); err == nil { + t.Fatal("minimum step mutation admitted by aggregate oracle") + } + }) + } } } diff --git a/scripts/workflow_python_runtime_test.go b/scripts/workflow_python_runtime_test.go index d09e4f4e..6f2aa03f 100644 --- a/scripts/workflow_python_runtime_test.go +++ b/scripts/workflow_python_runtime_test.go @@ -21,7 +21,7 @@ var hostPythonConsumer = regexp.MustCompile(`\bpython3\b|npm run (check|go:test| // Jobs whose run steps consume the host Python. Every other job must not. var expectedPythonJobs = map[string][]string{ - "ci.yml": {"platform-smoke", "platform-smoke-intel", "source-quality"}, + "ci.yml": {"platform-smoke", "platform-smoke-intel", "platform-smoke-linux-arm64", "source-quality"}, "release.yml": {"candidate", "publish-pypi", "publish-readiness"}, } @@ -175,6 +175,16 @@ func TestWorkflowPythonRuntimeRejectsUnpinnedConsumers(t *testing.T) { {"x86_64 platform runtime drift", "ci.yml", "platform-smoke-intel", func(job *githubJob) { job.Steps[setupStep(*job)].With["python-version"] = "3.14.7" }}, + {"Linux arm64 platform setup omitted", "ci.yml", "platform-smoke-linux-arm64", func(job *githubJob) { + index := setupStep(*job) + job.Steps = append(job.Steps[:index], job.Steps[index+1:]...) + }}, + {"Linux arm64 platform setup after smoke", "ci.yml", "platform-smoke-linux-arm64", func(job *githubJob) { + index := setupStep(*job) + step := job.Steps[index] + job.Steps = append(job.Steps[:index], job.Steps[index+1:]...) + job.Steps = append(job.Steps, step) + }}, {"new unpinned consumer", "release.yml", "release-metadata", func(job *githubJob) { job.Steps = append(job.Steps, githubStep{Name: "Read version", Run: "python3 -c 'print(1)'", runPresent: true}) }},