diff --git a/.github/actions/run-store/get-junit.py b/.github/actions/run-store/get-junit.py new file mode 100755 index 0000000..5974c97 --- /dev/null +++ b/.github/actions/run-store/get-junit.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Discover exact JUnit keys from authenticated producer step names, not wildcards.""" +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile + + +def api(path): + env = dict(os.environ, GH_TOKEN=os.environ.get("ACTIONS_TOKEN") or os.environ.get("GH_TOKEN", "")) + return json.loads(subprocess.check_output(["gh", "api", path], env=env, text=True, timeout=60)) + + +def entries(rows, run_id): + names = set() + for job in rows: + if str(job.get("run_id")) != str(run_id): + raise ValueError("job belongs to another run") + for step in job.get("steps", []): + name = step.get("name", "") + if step.get("conclusion") == "skipped": + continue + if name.startswith("run-store put junit-"): + name = name[len("run-store put "):] + if not re.fullmatch(r"junit-[A-Za-z0-9._-]{1,94}", name): + raise ValueError("invalid JUnit discovery name") + names.add(name) + return sorted(names) + + +def fetch(repo, run_id, destination): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo) or not re.fullmatch(r"[0-9]{1,20}", str(run_id)): + raise ValueError("invalid repository or run id") + repository_id = str(api(f"repos/{repo}")["id"]) + rows = [] + page = 1 + while True: + batch = api(f"repos/{repo}/actions/runs/{run_id}/jobs?filter=latest&per_page=100&page={page}")["jobs"] + rows.extend(batch) + if len(batch) < 100: + break + page += 1 + names = entries(rows, run_id) + lanes = {} + for job in rows: + for name in entries([job], run_id): + lane = job.get("name") + if not isinstance(lane, str) or not lane.strip(): + raise ValueError("producer job has no lane name") + lane = lane.replace(",", ";") + if name in lanes and lanes[name] != lane: + raise ValueError("JUnit key is shared by different jobs") + lanes[name] = lane + found = bool(names) + with tempfile.TemporaryDirectory(prefix="junit-store-") as tmp: + stage = Path(tmp) / "entries" + for index, name in enumerate(names): + output = Path(tmp) / f"output-{index}" + env = dict(os.environ, MODE="get", NAME=name, STORE_PATH=str(stage / name), + STORE_RUN_ID=str(run_id), REPO_ID=repository_id, REQUIRED="false", GITHUB_OUTPUT=str(output)) + subprocess.run(["bash", str(Path(__file__).with_name("run-store.sh"))], env=env, check=True, timeout=3600) + if not output.exists() or "found=true" not in output.read_text().splitlines(): + found = False + break + # Never expose a partial shard set as evidence that a failed test passed. + if found: + Path(destination).mkdir(parents=True, exist_ok=True) + for name in names: + shutil.move(str(stage / name), str(Path(destination) / name)) + (Path(destination) / ".run-store-lanes.json").write_text(json.dumps(lanes)) + if os.environ.get("GITHUB_OUTPUT"): + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"found={str(found).lower()}\n") + return found + + +if __name__ == "__main__": + try: + repo, run_id, destination = sys.argv[1:] + sys.exit(0 if fetch(repo, run_id, destination) else 1) + except Exception as error: + print(f"::warning::JUnit run-store discovery failed: {error}", file=sys.stderr) + sys.exit(1) diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml index 713bc3e..5bbd092 100644 --- a/.github/workflows/ios-release.yml +++ b/.github/workflows/ios-release.yml @@ -25,17 +25,17 @@ on: inputs: ipa-artifact: description: > - Prebuilt mode. Name of an artifact holding exactly one signed .ipa, - uploaded by an earlier job of the caller's same run (which this job + Prebuilt mode. Name of a run-store entry holding exactly one signed .ipa, + stored by an earlier job of the caller's same run (which this job `needs:`). When set, checkout, keychain, archive and export are skipped. required: false type: string default: '' xcode-artifact: description: > - Build mode only. Name of an artifact holding an exported Xcode - project (for example Unity's export from Linux), uploaded by an - earlier job of the caller's same run. Downloaded into the checkout + Build mode only. Name of a run-store entry holding an exported Xcode + project (for example Unity's export from Linux), stored by an + earlier job of the caller's same run. Fetched into the checkout at artifact-path before the build. required: false type: string @@ -125,6 +125,7 @@ on: permissions: contents: read + id-token: write jobs: prepare: @@ -207,11 +208,12 @@ jobs: echo "::error::Xcode $IN_XCODE_VERSION is not installed; installed:${installed:- none}" exit 1 - - name: Download Xcode project artifact + - name: Download Xcode project entry if: inputs.ipa-artifact == '' && inputs.xcode-artifact != '' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: SylphxAI/.github/.github/actions/run-store@c46a1cd933a242b1471afb26850ff5d8f5d52800 timeout-minutes: 10 with: + mode: get name: ${{ inputs.xcode-artifact }} path: ${{ inputs.artifact-path }} @@ -240,15 +242,12 @@ jobs: - name: Upload prepared workspace if: inputs.ipa-artifact == '' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - # Quota exhaustion must never eject a merge_group entry; elsewhere a failed upload stays loud. - continue-on-error: ${{ github.event_name == 'merge_group' }} + uses: SylphxAI/.github/.github/actions/run-store@c46a1cd933a242b1471afb26850ff5d8f5d52800 timeout-minutes: 15 with: + mode: put name: ios-workspace-${{ inputs.bundle-id }} path: ${{ runner.temp }}/ios-workspace.tar - retention-days: 1 - if-no-files-found: error sign: @@ -438,9 +437,10 @@ jobs: exit 1 - name: Download prepared workspace - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: SylphxAI/.github/.github/actions/run-store@c46a1cd933a242b1471afb26850ff5d8f5d52800 timeout-minutes: 15 with: + mode: get name: ios-workspace-${{ inputs.bundle-id }} path: ${{ runner.temp }}/ios-signing/workspace-dl @@ -606,14 +606,11 @@ jobs: - name: Upload exported .ipa timeout-minutes: 15 - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - # Quota exhaustion must never eject a merge_group entry; elsewhere a failed upload stays loud. - continue-on-error: ${{ github.event_name == 'merge_group' }} + uses: SylphxAI/.github/.github/actions/run-store@c46a1cd933a242b1471afb26850ff5d8f5d52800 with: + mode: put name: ios-ipa-${{ inputs.bundle-id }} - path: ${{ runner.temp }}/ios-signing/export/*.ipa - retention-days: 1 - if-no-files-found: error + path: ${{ runner.temp }}/ios-signing/export - name: Cleanup (keychain, profiles, API key, temporary files) if: always() @@ -806,9 +803,10 @@ jobs: done - name: Download .ipa - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: SylphxAI/.github/.github/actions/run-store@c46a1cd933a242b1471afb26850ff5d8f5d52800 timeout-minutes: 10 with: + mode: get name: ${{ inputs.ipa-artifact != '' && inputs.ipa-artifact || format('ios-ipa-{0}', inputs.bundle-id) }} path: ${{ runner.temp }}/ios-signing/export diff --git a/.github/workflows/junit-consumer-control.yml b/.github/workflows/junit-consumer-control.yml new file mode 100644 index 0000000..a54f1a2 --- /dev/null +++ b/.github/workflows/junit-consumer-control.yml @@ -0,0 +1,19 @@ +name: Cross-run JUnit consumer control + +on: + workflow_run: + workflows: [Project control] + types: [completed] + +permissions: + actions: read + contents: read + id-token: write + +jobs: + consumer: + # Dispatch already calls the same consumer at the selected PR ref. + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'workflow_dispatch' + uses: ./.github/workflows/junit-consumer.yml + with: + producer-run: ${{ format('{0}', github.event.workflow_run.id) }} diff --git a/.github/workflows/junit-consumer.yml b/.github/workflows/junit-consumer.yml new file mode 100644 index 0000000..8b9c4f8 --- /dev/null +++ b/.github/workflows/junit-consumer.yml @@ -0,0 +1,36 @@ +name: JUnit run-store consumer + +on: + workflow_call: + inputs: + producer-run: + description: Completed Project control run containing the two JUnit fixture shards. + required: true + type: string + +permissions: + actions: read + contents: read + id-token: write + +jobs: + consume: + runs-on: ${{ github.event.repository.private && 'sylphx-linux-standard' || 'ubuntu-latest' }} + timeout-minutes: 10 + steps: + # Same source binding as red-main: never execute a producer commit. + - name: Read canonical run-store consumer + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: SylphxAI/.github + ref: ${{ job.workflow_sha }} + persist-credentials: false + path: consumer-shared + - name: Install parser dependency + run: python3 -m pip install --quiet pyyaml + - name: Fetch exact cross-run JUnit and verify identity + env: + GH_TOKEN: ${{ github.token }} + PRODUCER_RUN: ${{ inputs.producer-run }} + CONSUMER_SOURCE: ${{ job.workflow_sha }} + run: python3 consumer-shared/scripts/check-junit-consumer.py diff --git a/.github/workflows/project-control.yml b/.github/workflows/project-control.yml index dcfd087..f3c04ed 100644 --- a/.github/workflows/project-control.yml +++ b/.github/workflows/project-control.yml @@ -6,6 +6,12 @@ on: branches: - main pull_request: + workflow_dispatch: + inputs: + junit-producer-run: + description: 'Completed Project control producer run to read from a different run.' + required: true + type: string concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} @@ -51,6 +57,13 @@ jobs: run: python -m pip install --quiet pyyaml - name: Unit tests run: python -m unittest discover -s tests + - name: Lint authenticated JUnit consumer workflows + uses: ./.github/actions/workflow-lint + with: + args: | + .github/workflows/project-control.yml + .github/workflows/junit-consumer.yml + .github/workflows/junit-consumer-control.yml - name: Prepare metadata sync consumer fixture env: SYNC_FIXTURE: ${{ runner.temp }}/metadata-consumer @@ -100,6 +113,45 @@ jobs: with: brand-directory: ${{ runner.temp }}/brand-consumer/brand data + junit-producer: + if: github.event_name != 'workflow_dispatch' + name: JUnit fixture (${{ matrix.shard }}) + # Standard hosted runners are free only while this repository is public. + runs-on: ${{ github.event.repository.private && 'sylphx-linux-standard' || 'ubuntu-latest' }} + timeout-minutes: 5 + permissions: + contents: read + id-token: write + strategy: + matrix: + shard: [1, 701] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Prepare nested passing and failing JUnit cases + env: + SHARD: ${{ matrix.shard }} + run: | + mkdir -p "$RUNNER_TEMP/junit/nested" + printf '\n' "$SHARD" "$SHARD" > "$RUNNER_TEMP/junit/nested/junit.xml" + - name: run-store put junit-consumer-${{ matrix.shard }} + uses: ./.github/actions/run-store + with: + mode: put + name: junit-consumer-${{ matrix.shard }} + path: ${{ runner.temp }}/junit + + junit-consumer: + if: github.event_name == 'workflow_dispatch' + permissions: + actions: read + contents: read + id-token: write + uses: ./.github/workflows/junit-consumer.yml + with: + producer-run: ${{ inputs.junit-producer-run }} + # The optimistic-merge actions, run as a consumer runs them: a manifest the # runner cannot load (an expression in a description, a bad input) fails # here, before any repository pins it (#111). The starters are linted as @@ -179,7 +231,7 @@ jobs: # Aggregate verdict required by the main merge queue ruleset. Skipped jobs # pass; failed or cancelled jobs fail. ci-ok: - needs: [project-control, tests, template-plan, template-verdict] + needs: [project-control, tests, template-plan, template-verdict, junit-producer, junit-consumer] if: always() runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.github/workflows/red-main.yml b/.github/workflows/red-main.yml index 67af7f4..43cb919 100644 --- a/.github/workflows/red-main.yml +++ b/.github/workflows/red-main.yml @@ -265,9 +265,9 @@ concurrency: # GITHUB_TOKEN carries, and a called workflow can only narrow that grant (an # explicit request above it fails the call), so this handler requests nothing. # Every caller grants `actions: write` (dispatch of the verify workflow, -# runs, jobs and artifacts, all through `github.token`) and `issues: write` -# (the notice when a mint or probe fails), plus `contents: read` and -# `checks: read`. The github.token is never handed to anything that runs +# runs and jobs, all through `github.token`) and `issues: write` +# (the notice when a mint or probe fails), plus `contents: read`, +# `id-token: write` (run-store reads) and `checks: read`. The github.token is never handed to anything that runs # pull-request-controlled code. A caller with the builder App also has the # handler push and open pull requests through the App installation token, # which needs contents, issues, pull-requests and workflows write and no @@ -623,6 +623,7 @@ jobs: # own default branch from the event payload, else `main`. TRUNK: ${{ inputs.trunk || github.event.repository.default_branch || 'main' }} REPO: ${{ github.repository }} + RUN_STORE_JUNIT: ${{ github.workspace }}/.red-main-store/.github/actions/run-store/get-junit.py VERIFY_WORKFLOW: ${{ inputs.verify-workflow }} VERIFY_CHECK_NAME: ${{ inputs.verify-check-name || 'verified' }} MAX_CANDIDATES: ${{ inputs.max-candidates }} @@ -1335,6 +1336,7 @@ jobs: # `lanesignature` file from `proof.py failed-units` (conclusion, # failed steps, error lines), else by the lane name alone. JUNIT_PY: | + import json import os import sys import xml.etree.ElementTree as ElementTree @@ -1353,6 +1355,11 @@ jobs: if lane and signature: signatures[clean(lane)] = clean(signature) + lane_map = {} + mapping = os.path.join(root, ".run-store-lanes.json") + if os.path.isfile(mapping): + with open(mapping) as fh: + lane_map = json.load(fh) if os.path.isdir(root): for dirpath, dirnames, filenames in os.walk(root): for filename in filenames: @@ -1360,6 +1367,8 @@ jobs: continue lane = os.path.basename(dirpath) lane = lane[len("junit-"):] if lane.startswith("junit-") else lane + entry = os.path.relpath(dirpath, root).split(os.sep)[0] + lane = lane_map.get(entry, lane) try: tree = ElementTree.parse(os.path.join(dirpath, filename)) except Exception: @@ -1513,6 +1522,7 @@ jobs: other way may hide the target (an earlier step failed, so the target's step never ran), so it prints `inconclusive `; else `pass `. """ + import json import os import shutil import subprocess @@ -1566,9 +1576,16 @@ jobs: failing, passing, covered = set(), set(), set() if not os.path.isdir(root): return failing, passing, covered + lane_map = {} + mapping = os.path.join(root, ".run-store-lanes.json") + if os.path.isfile(mapping): + with open(mapping) as fh: + lane_map = json.load(fh) for dirpath, _dirs, files in os.walk(root): lane = os.path.basename(dirpath) lane = lane[len("junit-"):] if lane.startswith("junit-") else lane + entry = os.path.relpath(dirpath, root).split(os.sep)[0] + lane = lane_map.get(entry, lane) for filename in files: if not filename.endswith(".xml"): continue @@ -1672,9 +1689,8 @@ jobs: try: env = dict(os.environ) env["GH_TOKEN"] = env.get("ACTIONS_TOKEN") or env.get("GH_TOKEN", "") - subprocess.run(["gh", "run", "download", str(run["id"]), "--repo", repo, - "--pattern", "junit-*", "--dir", work], - env=env, capture_output=True, timeout=120) + subprocess.run(["python3", env["RUN_STORE_JUNIT"], repo, str(run["id"]), work], + env=env, capture_output=True, timeout=600) return read_junit(work) except Exception: return set(), set(), set() @@ -1845,6 +1861,16 @@ jobs: # --------------------------------------------------------------------- # The helpers, on disk for the steps that follow. # --------------------------------------------------------------------- + - name: Read canonical run-store consumer + if: steps.gate.outputs.proceed == 'true' + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + repository: SylphxAI/.github + ref: ${{ job.workflow_sha }} + path: .red-main-store + persist-credentials: false + sparse-checkout: .github/actions/run-store + - name: Prepare the handler helpers id: helpers if: steps.gate.outputs.proceed == 'true' @@ -2157,10 +2183,10 @@ jobs: set -euo pipefail . "$RUNNER_TEMP/red-main/lib.sh" mkdir -p "$WORK_DIR/junit" - if gha run download "$RUN_ID" --repo "$REPO" --pattern 'junit-*' --dir "$WORK_DIR/junit" 2>"$WORK_DIR/junit.err"; then - echo "downloaded the run's junit artifacts" + if python3 "$RUN_STORE_JUNIT" "$REPO" "$RUN_ID" "$WORK_DIR/junit" 2>"$WORK_DIR/junit.err"; then + echo "read the run's junit entries from run-store" else - say "The run published no \`junit-*\` artifact, so a failing lane is named by how it failed (its failed steps and error lines) rather than by a test." + say "The run published no readable \`junit-*\` run-store entry, so a failing lane is named by how it failed (its failed steps and error lines) rather than by a test." fi # How each failed job failed: a lane unit is its job, failed steps # and error lines, so one job failing two different ways is two @@ -2290,7 +2316,7 @@ jobs: : >"$WORK_DIR/previous-failing-tests.tsv" if [ "$prev_conclusion" = failure ] || [ "$prev_conclusion" = timed_out ]; then mkdir -p "$WORK_DIR/junit-previous" - gha run download "$prev_id" --repo "$REPO" --pattern 'junit-*' --dir "$WORK_DIR/junit-previous" 2>/dev/null || true + python3 "$RUN_STORE_JUNIT" "$REPO" "$prev_id" "$WORK_DIR/junit-previous" 2>/dev/null || true if ! python3 "$RUNNER_TEMP/red-main/proof.py" failed-units "$REPO" "$TRUNK" "$VERIFY_WORKFLOW" "$prev_id" \ >"$WORK_DIR/previous-lane-signatures.tsv"; then say "**Unverified:** previous failing lanes are unavailable; escalating without confirmation." @@ -2597,7 +2623,7 @@ jobs: [ "${conclusion_of[$sha]}" = failure ] || continue candidate_dir="$WORK_DIR/junit-candidate-${run_of[$sha]}" mkdir -p "$candidate_dir" - gha run download "${run_of[$sha]}" --repo "$REPO" --pattern 'junit-*' --dir "$candidate_dir" 2>/dev/null || true + python3 "$RUN_STORE_JUNIT" "$REPO" "${run_of[$sha]}" "$candidate_dir" 2>/dev/null || true if ! python3 "$RUNNER_TEMP/red-main/proof.py" failed-units "$REPO" "$TRUNK" "$VERIFY_WORKFLOW" "${run_of[$sha]}" \ >"$WORK_DIR/candidate-signatures.tsv" 2>/dev/null; then conclusion_of[$sha]=inconclusive diff --git a/docs/ios-release.md b/docs/ios-release.md index 648aa1b..bf7a699 100644 --- a/docs/ios-release.md +++ b/docs/ios-release.md @@ -31,6 +31,11 @@ Copy [`workflow-templates/ios-release.yml`](../workflow-templates/ios-release.ym Pin the workflow by commit. There is no `secrets:` block and no `secrets: inherit`: the signing job names a GitHub environment and reads the secrets from it (see Secrets). +The caller grants `permissions: id-token: write` as well as `contents: read`. +All job handovers use [`run-store`](run-store.md): the prepared workspace, +Xcode export and signed IPA are ephemeral seven-day entries, not GitHub +artifacts. This workflow uploads the final IPA to TestFlight; it is not a +durable archive or audit-evidence store. Build mode, Xcode repository: @@ -44,12 +49,12 @@ jobs: project: MyApp.xcodeproj ``` -Build mode, Unity (an earlier job exports Xcode on Linux and uploads the -artifact `ios-build`; this job `needs:` it): +Build mode, Unity (an earlier job exports Xcode on Linux and stores the +run-store entry `ios-build`; this job `needs:` it): ```yaml jobs: - export: # Linux: Unity export, then actions/upload-artifact name: ios-build + export: # Linux: Unity export, then run-store mode: put, name: ios-build testflight: needs: export uses: SylphxAI/.github/.github/workflows/ios-release.yml@ @@ -63,8 +68,8 @@ jobs: xcode-version: '26.3' ``` -Prebuilt mode, Solar2D (an earlier job builds and signs the `.ipa` and uploads -it as the artifact `ipa`; this job `needs:` it): +Prebuilt mode, Solar2D (an earlier job builds and signs the `.ipa` and stores +it as the run-store entry `ipa`; this job `needs:` it): ```yaml jobs: @@ -82,8 +87,8 @@ jobs: | --- | --- | --- | --- | | `bundle-id` | string, required | | App bundle id; selects the archive profile (build) or must equal the `.ipa`'s `CFBundleIdentifier` (prebuilt) | | `environment` | string | `ios-release` | Environment of the signing job; must not be empty | -| `ipa-artifact` | string | `''` | Prebuilt mode: artifact holding exactly one signed `.ipa`, from an earlier job of the same run | -| `xcode-artifact` | string | `''` | Build mode: artifact holding an exported Xcode project, downloaded before the build | +| `ipa-artifact` | string | `''` | Prebuilt mode: run-store entry holding exactly one signed `.ipa`, from an earlier job of the same run | +| `xcode-artifact` | string | `''` | Build mode: run-store entry holding an exported Xcode project, fetched before the build | | `artifact-path` | string | `.` | Repo-relative directory the `xcode-artifact` is downloaded into | | `pre-build-script` | string | `''` | Build mode: script run with `bash` in the `prepare` job, before any secret exists (relative to the artifact directory if `xcode-artifact` is set, else the repository root) | | `post-archive-script` | string | `''` | Build mode: same rules; runs after archive and before export, in a step with no signing secrets. The temporary keychain exists at that point (partition list `codesign:` only) | @@ -126,16 +131,16 @@ them. These names match the Cubeage organization secrets. 1. `prepare` (no environment, no secret; the caller's own pre-build code runs here): checks out the ref, selects `xcode-version`, downloads - `xcode-artifact`, runs `pre-build-script`, and uploads the prepared - workspace as an artifact (one day retention). In prebuilt mode it only + `xcode-artifact`, runs `pre-build-script`, and stores the prepared + workspace in run-store (seven-day expiry). In prebuilt mode it only validates the inputs. 2. `sign` (build mode only; environment; holds ONLY the four signing secrets): - the build steps below, then uploads the exported `.ipa` as an artifact (one - day retention). Caller code runs in this job (Run Script phases, package + the build steps below, then stores the exported `.ipa` in run-store (seven-day + expiry). Caller code runs in this job (Run Script phases, package plugins, the post-archive script), so it never references an App Store Connect secret. 3. `upload` (environment; the ONLY job with the `APP_STORE_CONNECT_*` - secrets): on a fresh machine it downloads the `.ipa` (the `sign` artifact, + secrets): on a fresh machine it fetches the `.ipa` (the `sign` entry, or `ipa-artifact` in prebuilt mode), validates it, runs a process check, then writes the key and uploads. diff --git a/docs/optimistic-merge.md b/docs/optimistic-merge.md index 6ad0386..9000f83 100644 --- a/docs/optimistic-merge.md +++ b/docs/optimistic-merge.md @@ -50,16 +50,16 @@ proofs - is a suite lane. 3. **`.github/workflows/verify.yml`** from [`workflow-templates/optimistic-verify.yml`](../workflow-templates/optimistic-verify.yml): the suite lanes and the aggregate job named exactly `verified`. Each lane - that runs tests uploads a JUnit report as `junit-` (failing and passing - testcases); the handler names failing tests and each test's trace window - from it. The upload is a diagnostic: it carries `continue-on-error: true` - and `retention-days: 3`, so a spent organization artifact quota never turns - a passing lane red ([run-store](run-store.md)). Its input `path` is the - report file only: a working tree copied with it, a `**/*.bundle` or a - hand-entry that drops a large blob in `workflow-templates` and uploads it, - *does* spend the quota and holds the whole organization's CI down until the - window recalculates (OzyrixLtd, 2026-10-07). This document is the complete - set of steps that stand between the copy and a working pipeline; a lane's own + that runs tests stores a JUnit report as `junit-` (failing and passing + testcases) through [run-store](run-store.md); the handler names failing tests + and each test's trace window from it. Its top-level producer step is named + exactly `run-store put ` with the same expanded name input, including + matrix shard suffixes. This is the explicit discovery contract for exact + cross-run keys; the gateway does not support wildcard listing. Publish with + `if: always()`, `continue-on-error: true` and `required: "false"` so missing + optional diagnostics never turn a passing lane red. Producers and handler + callers grant `id-token: write`; keep `path` on the report file only. + This document is the complete set of steps between the copy and a working pipeline; a lane's own material is the caller's, install steps included, and is never added here. A file or directory another job of the same run needs is a hand-over: use [`run-store`](run-store.md), never `upload-artifact`. @@ -72,8 +72,9 @@ proofs - is a suite lane. token mode: a comment on the affected pull requests, never a pull request of its own - a person reverts. The App key never leaves its organization. Token split: every Actions call (runs, jobs, - artifacts, dispatch of the verify workflow) uses the caller's - `github.token`, so the caller grants `actions: write`; the builder App + dispatch of the verify workflow) uses the caller's + `github.token`, so the caller grants `actions: write` and `id-token: write` + for run-store diagnostic reads; the builder App installation needs no `actions` permission, only contents, issues, pull-requests and workflows write (a verify or revert branch can point at a commit with older workflow files), and is used for what must start CI (verify and revert diff --git a/docs/run-store.md b/docs/run-store.md index c0f6f73..dadc8cc 100644 --- a/docs/run-store.md +++ b/docs/run-store.md @@ -74,6 +74,58 @@ Outputs: `key`, `found` (get), `bytes`. Like `actions/upload-artifact`, a directory's contents are stored (not the directory itself), and a single file is unpacked as `/`. +## JUnit discovery across runs + +The gateway has no wildcard/list API. Diagnostic producers MUST explicitly name +their top-level step `run-store put `, with the same `name` input beginning +with `junit-`. For matrix jobs expand the same matrix value in both fields, for +example `run-store put junit-rust-test-${{ matrix.shard }}` and +`name: junit-rust-test-${{ matrix.shard }}`. Publish passing and failing testcases +with `if: always()`, `required: "false"` and `continue-on-error: true`. + +The action's `get-junit.py ` helper pages the +authenticated run jobs API (`filter=latest`), validates the run identity and +discovers exact keys from these step names, excluding skipped producers. It then +uses the existing exact-name get contract, unpacking each entry into its own +`` directory. No static lane list or shard limit is assumed. +The repository id comes from the target repository API, not the shared workflow +source repository; run ids are always the producer's, including previous and +candidate runs. Callers need `actions: read`, `contents: read`, `id-token: write` +and `GH_TOKEN` (or `ACTIONS_TOKEN`) for those metadata reads. + +The helper also writes `.run-store-lanes.json`, mapping each stored name to its +authenticated GitHub job name. Both failed-unit and regression-window parsers +use that mapping for sharded reports (including nested XML paths), so a test is +attributed to the actual matrix job rather than a guessed suffix. A partial +shard fetch publishes no reports: unread diagnostics cannot clear a failed test. + +The handler checks out this helper at its own `job.workflow_sha`, never at a +failing run's commit. BuildCache's event-scoped token remains authoritative: +dev consumers can read protected entries but cannot overwrite them. Missing, +expired or unavailable diagnostics retain the handler's lane-evidence fallback; +they never become proof that a failed test passed. + +## Authenticated consumer check + +Project control publishes two real nested JUnit fixture shards (1 and 701) through +run-store. Its completion triggers `junit-consumer-control.yml`, which invokes +`junit-consumer.yml` in a different run with `actions: read` and `id-token: write`. +The reusable consumer checks out shared source at `job.workflow_sha`, calls the +unmocked discovery command and verifies both red-main parsers against the exact +matrix job names and passing/failing test identities. No GitHub artifact is used. +Publication, authentication, a missing shard or an identity mismatch fails this +check rather than silently skipping it. The summary records source SHA, producer +run URL and producer SHA for the consumer invocation evidence. + +To exercise a replacement PR head before merge, first let its normal Project +control run finish publishing the fixtures. Dispatch the existing +`project-control.yml` workflow at that PR branch with `junit-producer-run` set to +that completed producer run ID. The dispatch calls the reusable consumer at the +selected branch, and `ci-ok` includes its verdict. The automatic completion +consumer does not duplicate this dispatch. The pre-merge dispatch is necessary +for a newly added workflow: completion triggers load the default-branch caller. +These checks need no builder App secret or manually copied credential. + ## Behaviour - The key is `run-store.v1...`. Re-running only diff --git a/scripts/check-junit-consumer.py b/scripts/check-junit-consumer.py new file mode 100644 index 0000000..dc797aa --- /dev/null +++ b/scripts/check-junit-consumer.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Real cross-run consumer proof. No metadata, OIDC or store transport mocks.""" +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +def api(path): + return json.loads(subprocess.check_output(['gh', 'api', path], text=True, timeout=60)) + + +def main(): + repo = os.environ['GITHUB_REPOSITORY'] + run = os.environ['PRODUCER_RUN'] + if not re.fullmatch(r'[0-9]{1,20}', run) or run == os.environ['GITHUB_RUN_ID']: + raise ValueError('Consumer requires a different numeric producer run id') + producer = api(f'repos/{repo}/actions/runs/{run}') + if (producer['repository']['full_name'] != repo + or producer['path'] != '.github/workflows/project-control.yml' + or producer['status'] != 'completed' or producer['conclusion'] != 'success'): + raise ValueError('Expected a completed successful Project control producer run') + + with tempfile.TemporaryDirectory(dir=os.environ['RUNNER_TEMP'], prefix='junit-consumer-') as tmp: + reports = Path(tmp) / 'reports' + subprocess.run([sys.executable, str(ROOT / '.github/actions/run-store/get-junit.py'), + repo, run, str(reports)], check=True, timeout=480) + expected_lanes = {f'junit-consumer-{shard}': f'JUnit fixture ({shard})' for shard in (1, 701)} + lanes = json.loads((reports / '.run-store-lanes.json').read_text()) + if lanes != expected_lanes: + raise AssertionError(f'Producer discovery/lane attribution mismatch: {lanes!r}') + for name in expected_lanes: + if not (reports / name / 'nested/junit.xml').is_file(): + raise AssertionError(f'Missing nested shard report: {name}') + + # Execute the actual shared red-main parsers, not a test reimplementation. + env = yaml.safe_load((ROOT / '.github/workflows/red-main.yml').read_text())['jobs']['red-main']['env'] + units = Path(tmp) / 'units.tsv' + parser = Path(tmp) / 'junit.py' + parser.write_text(env['JUNIT_PY']) + subprocess.run([sys.executable, str(parser), str(reports), str(units), + ','.join(expected_lanes.values())], check=True, timeout=30) + expected_failed = {(f'suite::broken-{shard}', f'JUnit fixture ({shard})') for shard in (1, 701)} + expected_rows = {f'test\t{name}\t{lane}' for name, lane in expected_failed} + if set(units.read_text().splitlines()) != expected_rows: + raise AssertionError('Failed-unit parser lost exact test or matrix job identity') + window = {'__name__': 'consumer'} + exec(compile(env['WINDOW_PY'], 'window.py', 'exec'), window) + failed, passed, covered = window['read_junit'](str(reports)) + expected_passed = {(f'suite::passing-{shard}', f'JUnit fixture ({shard})') for shard in (1, 701)} + if (failed, passed, covered) != (expected_failed, expected_passed, set(expected_lanes.values())): + raise AssertionError('Regression-window parser lost passing/failing shard identity') + + evidence = (f'Consumer source `{os.environ["CONSUMER_SOURCE"]}`; producer ' + f'[{run}](https://github.com/{repo}/actions/runs/{run}) at `{producer["head_sha"]}`. ' + 'Authenticated discovery and OIDC run-store reads retrieved shards 1 and 701; ' + 'both red-main parsers preserved nested reports and exact passing/failing test/job identity.') + print(evidence) + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as summary: + summary.write(evidence + '\n') + + +if __name__ == '__main__': + main() diff --git a/tests/test_ci_range.py b/tests/test_ci_range.py index bbc90db..c06ac4c 100644 --- a/tests/test_ci_range.py +++ b/tests/test_ci_range.py @@ -241,27 +241,32 @@ def test_verify_starter_hand_over_keeps_a_bounded_size(self) -> None: self.assertNotIn("*", str(step["with"]["path"]), f"{job_name} {step.get('name')}") guide = (ROOT / "docs" / "optimistic-merge.md").read_text() self.assertIn("never `upload-artifact`", guide) - self.assertIn("complete\n set of steps", guide) + self.assertIn("complete set of steps", " ".join(guide.split())) - def test_starter_artifact_uploads_never_fail_a_job(self) -> None: - # Artifact storage is an organization quota; once it is spent every - # upload fails ("Artifact storage quota has been hit") and a passing - # suite went red (docs/run-store.md). A starter's upload is a - # diagnostic, so it may not fail its job and keeps a short retention. + def test_starter_diagnostics_use_optional_run_store_not_github_artifacts(self) -> None: + # Starters must not copy GitHub's metered artifact dependency into a + # caller. Optional JUnit diagnostics use the run-store discovery + # contract and must never turn a passing test job red. import yaml seen = 0 for path in (ROOT / "workflow-templates").glob("*.yml"): workflow = yaml.safe_load(path.read_text()) for job_name, job in (workflow.get("jobs") or {}).items(): for step in job.get("steps") or []: - if not str(step.get("uses", "")).startswith("actions/upload-artifact@"): + uses = str(step.get("uses", "")) + where = f"{path.name} {job_name} {step.get('with', {}).get('name')}" + self.assertFalse(uses.startswith(("actions/upload-artifact@", "actions/download-artifact@")), where) + inputs = step.get("with") or {} + if "/actions/run-store@" not in uses or not str(inputs.get("name", "")).startswith("junit-"): continue seen += 1 - where = f"{path.name} {job_name} {step.get('with', {}).get('name')}" + self.assertEqual(inputs.get("mode"), "put", where) + self.assertEqual(step.get("name"), f"run-store put {inputs['name']}", where) + self.assertEqual(step.get("if"), "always()", where) self.assertIs(step.get("continue-on-error"), True, where) - retention = (step.get("with") or {}).get("retention-days") - self.assertIsInstance(retention, int, where) - self.assertLessEqual(retention, 3, where) + self.assertEqual(inputs.get("required"), "false", where) + permissions = job.get("permissions", workflow.get("permissions", {})) + self.assertEqual(permissions.get("id-token"), "write", where) self.assertGreater(seen, 0) diff --git a/tests/test_ios_release.py b/tests/test_ios_release.py index ea63d4a..572bf0c 100644 --- a/tests/test_ios_release.py +++ b/tests/test_ios_release.py @@ -79,12 +79,7 @@ def test_every_step_has_a_timeout_and_no_swallowed_failure(self) -> None: self.assertIn("timeout-minutes", s, s["name"]) self.assertIn("timeout-minutes", self.prepare) self.assertNotIn("|| true", self.text) - # The one allowed form: an upload may not fail a merge_group run (org - # artifact quota); outside merge_group it stays loud. - allowed = "continue-on-error: ${{ github.event_name == 'merge_group' }}" - for line in self.text.splitlines(): - if "continue-on-error" in line and not line.lstrip().startswith("#"): - self.assertEqual(line.strip(), allowed) + self.assertNotIn("continue-on-error", self.text) def test_cleanup_is_per_item_not_aborting(self) -> None: body = self.step("Cleanup")["run"] @@ -241,10 +236,21 @@ def test_upload_job_has_the_environment_and_follows_sign(self) -> None: self.assertEqual(self.upload["needs"], ["prepare", "sign"]) self.assertEqual(self.job["if"], "inputs.ipa-artifact == ''") self.assertIn("needs.sign.result == 'skipped'", self.upload["if"]) - art = self.step("Upload exported .ipa") - self.assertRegex(art["uses"], r"^actions/upload-artifact@[0-9a-f]{40}$") - self.assertEqual(art["with"]["retention-days"], 1) - self.assertRegex(self.step("Download .ipa", self.upload)["uses"], r"^actions/download-artifact@[0-9a-f]{40}$") + action = r"^SylphxAI/\.github/\.github/actions/run-store@[0-9a-f]{40}$" + for job, name, mode in ( + (self.prepare, "Download Xcode", "get"), + (self.prepare, "Upload prepared workspace", "put"), + (self.job, "Download prepared workspace", "get"), + (self.job, "Upload exported .ipa", "put"), + (self.upload, "Download .ipa", "get"), + ): + step = self.step(name, job) + self.assertRegex(step["uses"], action) + self.assertEqual(step["with"]["mode"], mode) + self.assertNotIn("retention-days", step["with"]) + self.assertEqual(self.doc["permissions"]["id-token"], "write") + self.assertNotIn("actions/upload-artifact@", self.text) + self.assertNotIn("actions/download-artifact@", self.text) def test_sign_job_holds_only_the_signing_secrets(self) -> None: dump = yaml.safe_dump(self.job) diff --git a/tests/test_junit_consumer_control.py b/tests/test_junit_consumer_control.py new file mode 100644 index 0000000..7450a51 --- /dev/null +++ b/tests/test_junit_consumer_control.py @@ -0,0 +1,58 @@ +"""Keep the live consumer proof connected to actual producers and shared source.""" +from pathlib import Path +import unittest + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +def workflow(name): + # BaseLoader keeps GitHub's `on` key literal rather than YAML 1.1 boolean. + return yaml.load((ROOT / '.github/workflows' / name).read_text(), Loader=yaml.BaseLoader) + + +class JunitConsumerControlTest(unittest.TestCase): + def test_exact_matrix_producers_and_dispatch_are_gated(self): + control = workflow('project-control.yml') + self.assertIn('junit-producer-run', control['on']['workflow_dispatch']['inputs']) + producer = control['jobs']['junit-producer'] + self.assertEqual(producer['strategy']['matrix']['shard'], ['1', '701']) + self.assertEqual(producer['name'], 'JUnit fixture (${{ matrix.shard }})') + self.assertEqual(producer['permissions']['id-token'], 'write') + put = producer['steps'][-1] + self.assertEqual(put['name'], 'run-store put ' + put['with']['name']) + self.assertEqual(put['uses'], './.github/actions/run-store') + self.assertNotIn('required', put['with']) # proof fails if real publication fails + consumer = control['jobs']['junit-consumer'] + self.assertEqual(consumer['uses'], './.github/workflows/junit-consumer.yml') + self.assertEqual(consumer['permissions']['actions'], 'read') + self.assertEqual(consumer['permissions']['id-token'], 'write') + self.assertIn('junit-producer', control['jobs']['ci-ok']['needs']) + self.assertIn('junit-consumer', control['jobs']['ci-ok']['needs']) + + def test_automatic_cross_run_and_shared_source_binding(self): + control = workflow('junit-consumer-control.yml') + self.assertEqual(control['on']['workflow_run']['workflows'], ['Project control']) + self.assertEqual(control['jobs']['consumer']['with']['producer-run'], + "${{ format('{0}', github.event.workflow_run.id) }}") + consumer = workflow('junit-consumer.yml') + steps = consumer['jobs']['consume']['steps'] + self.assertEqual(steps[0]['with']['ref'], '${{ job.workflow_sha }}') + self.assertEqual(consumer['permissions']['id-token'], 'write') + self.assertEqual(consumer['permissions']['actions'], 'read') + self.assertIn('check-junit-consumer.py', steps[-1]['run']) + for name in ('project-control.yml', 'junit-consumer.yml', 'junit-consumer-control.yml'): + text = (ROOT / '.github/workflows' / name).read_text() + self.assertNotIn('actions/upload-artifact', text) + self.assertNotIn('actions/download-artifact', text) + script = (ROOT / 'scripts/check-junit-consumer.py').read_text() + self.assertIn('get-junit.py', script) + self.assertIn("env['JUNIT_PY']", script) + self.assertIn("env['WINDOW_PY']", script) + self.assertIn("run == os.environ['GITHUB_RUN_ID']", script) + self.assertNotIn('unittest.mock', script) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_red_main_unavailable.py b/tests/test_red_main_unavailable.py index 1f2b149..5c1de53 100644 --- a/tests/test_red_main_unavailable.py +++ b/tests/test_red_main_unavailable.py @@ -115,6 +115,7 @@ def setUp(self): git.write_text('#!/bin/bash\ntouch "$RUNNER_TEMP/git.called"\nexit 1\n') git.chmod(0o755) self.env = dict(os.environ, RUNNER_TEMP=str(self.root), + RUN_STORE_JUNIT=str(ROOT / ".github/actions/run-store/get-junit.py"), PATH=f"{binaries}:{os.environ['PATH']}", GITHUB_OUTPUT=str(self.root / "output"), TRUNK="main", REPO="SylphxAI/cloud", VERIFY_WORKFLOW="verify.yml", VERIFY_CHECK_NAME="verified", diff --git a/tests/test_red_main_window.py b/tests/test_red_main_window.py index c0d350a..04f9f3e 100644 --- a/tests/test_red_main_window.py +++ b/tests/test_red_main_window.py @@ -320,16 +320,6 @@ def test_a_lane_row_confirms_nothing_that_failed_another_way(self): repo = fx["repo"] def jq(): return args[args.index("--jq") + 1] if "--jq" in args else "" -if args[0] == "run" and args[1] == "download": - rid, dest = args[2], args[args.index("--dir") + 1] - for lane, xml in fx["junit"].get(rid, {}).items(): - d = pathlib.Path(dest, "junit-" + lane) - d.mkdir(parents=True, exist_ok=True) - (d / "junit.xml").write_text(xml) - if not fx["junit"].get(rid): - print("no artifact matches", file=sys.stderr) - sys.exit(1) - sys.exit(0) path = next(a for a in args if a.startswith("repos/")) method = args[args.index("--method") + 1] if "--method" in args else "GET" base = f"repos/{repo}/" @@ -376,6 +366,18 @@ def jq(): sys.exit(1) ''' +FAKE_JUNIT = r''' +import json, os, pathlib, sys +fx = json.load(open(os.environ["FIXTURE"])) +repo, rid, dest = sys.argv[1:] +assert repo == fx["repo"] +for lane, xml in fx["junit"].get(rid, {}).items(): + d = pathlib.Path(dest, "junit-" + lane) + d.mkdir(parents=True, exist_ok=True) + (d / "junit.xml").write_text(xml) +sys.exit(0 if fx["junit"].get(rid) else 1) +''' + WRAPPED_PROOF = (ROOT / ".github/actions/ci-range/post_main.py").read_text().replace( 'if __name__ == "__main__":', 'last_verified = lambda *a, **k: os.environ["VERIFIED_BASE"]\n\n\nif __name__ == "__main__":') @@ -403,6 +405,8 @@ def setUp(self): gh.write_text(f"#!{sys.executable}\n" + FAKE_GH) gh.chmod(0o755) self.fixture = self.root / "fixture.json" + self.junit = self.root / "get-junit.py" + self.junit.write_text(FAKE_JUNIT) def run_trace(self, scenario, confirmed, candidates, candidate_junit=None, verified_base=0, failed_lanes="", mode="revert", annotations=None): @@ -433,6 +437,7 @@ def run_trace(self, scenario, confirmed, candidates, candidate_junit=None, verif RUN_ID=str(run_id(scenario.last)), RUN_URL="u", FAILED_LANES=failed_lanes, TOKEN_MODE="no", MODE=mode, ACTIONS_TOKEN="fixture", GH_TOKEN="fixture", VERIFIED_BASE=sha(verified_base)) + env["RUN_STORE_JUNIT"] = str(self.junit) done = subprocess.run(["bash", "-c", TRACE], env=env, cwd=self.root, capture_output=True, text=True, timeout=60) self.assertEqual(done.returncode, 0, done.stderr) diff --git a/tests/test_run_store_junit.py b/tests/test_run_store_junit.py new file mode 100755 index 0000000..c215289 --- /dev/null +++ b/tests/test_run_store_junit.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Exact cross-run diagnostic discovery; no gateway wildcard assumptions.""" +import importlib.util +import json +import os +import runpy +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import sys +import yaml + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / '.github/actions/run-store/get-junit.py' +spec = importlib.util.spec_from_file_location('store_junit', SCRIPT) +store = importlib.util.module_from_spec(spec) +spec.loader.exec_module(store) + + +class JunitDiscoveryTest(unittest.TestCase): + def test_matrix_names_deduplicate_and_skip_unexecuted_producers(self): + steps = [{'name': 'run-store put junit-rust-test-701', 'conclusion': 'success'}, + {'name': 'run-store put junit-rust-test-1', 'conclusion': 'failure'}, + {'name': 'run-store put junit-unused', 'conclusion': 'skipped'}, + {'name': 'upload artifact junit-old', 'conclusion': 'success'}] + rows = [{'run_id': 44, 'steps': steps}, {'run_id': 44, 'steps': steps}] + self.assertEqual(store.entries(rows, 44), ['junit-rust-test-1', 'junit-rust-test-701']) + + def test_invalid_identity_or_name_is_rejected(self): + with self.assertRaises(ValueError): + store.entries([{'run_id': 45, 'steps': []}], 44) + with self.assertRaises(ValueError): + store.entries([{'run_id': 44, 'steps': [{'name': 'run-store put junit-../../escape'}]}], 44) + + def exercise_fetch(self, miss=False, cli=False): + calls, gets = [], [] + first = [{'run_id': 44, 'name': 'rust-test (1)', 'steps': []} for _ in range(100)] + first[0]['steps'] = [{'name': 'run-store put junit-a'}] + second = [{'run_id': 44, 'name': 'rust-test (701)', 'steps': [{'name': 'run-store put junit-b'}]}] + def api(path): + calls.append(path) + if path == 'repos/tenant/project': + return {'id': 991} + return {'jobs': first if path.endswith('page=1') else second} + def run(args, env, **kwargs): + gets.append(env) + Path(env['STORE_PATH']).mkdir(parents=True) + (Path(env['STORE_PATH']) / 'junit.xml').write_text('') + Path(env['GITHUB_OUTPUT']).write_text('found=false\n' if miss and env['NAME'] == 'junit-b' else 'found=true\n') + with tempfile.TemporaryDirectory() as tmp, patch.object(store, 'api', api), patch.object(store.subprocess, 'run', run), patch.dict(os.environ, {'GITHUB_OUTPUT': str(Path(tmp) / 'out'), 'REPO_ID': 'wrong-source-id'}): + dest = Path(tmp) / 'reports' + if cli: + # Exercise the same entrypoint red-main invokes, with transport + # fixtures only; importing fetch alone misses CLI-only defects. + with patch.object(sys, 'argv', [str(SCRIPT), 'tenant/project', '44', str(dest)]), patch.object(store.subprocess, 'check_output', lambda args, **kwargs: json.dumps(api(args[-1]))): + with self.assertRaises(SystemExit) as exit_result: + runpy.run_path(str(SCRIPT), run_name='__main__') + self.assertEqual(exit_result.exception.code, 1 if miss else 0) + else: + self.assertEqual(store.fetch('tenant/project', '44', str(dest)), not miss) + self.assertEqual(dest.exists(), not miss) + if not miss: + self.assertTrue((dest / 'junit-b/junit.xml').exists()) + self.assertEqual(json.loads((dest / '.run-store-lanes.json').read_text()), + {'junit-a': 'rust-test (1)', 'junit-b': 'rust-test (701)'}) + self.assertEqual(len(calls), 3) + self.assertEqual([env['NAME'] for env in gets], ['junit-a', 'junit-b']) + self.assertTrue(all(env['REPO_ID'] == '991' and env['STORE_RUN_ID'] == '44' and env['MODE'] == 'get' for env in gets)) + + def test_pagination_target_repository_and_exact_keys(self): + self.exercise_fetch() + + def test_consumer_entrypoint_fetches_exact_cross_run_shards(self): + self.exercise_fetch(cli=True) + + def test_consumer_entrypoint_reports_unavailable_partial_shards(self): + self.exercise_fetch(miss=True, cli=True) + + def test_partial_shard_fetch_is_not_passing_evidence(self): + self.exercise_fetch(miss=True) + + def test_sharded_nested_reports_keep_exact_test_and_job_identity(self): + workflow = yaml.safe_load((ROOT / '.github/workflows/red-main.yml').read_text()) + env = workflow['jobs']['red-main']['env'] + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + report = root / 'junit-rust-test-701/nested' + report.mkdir(parents=True) + (report / 'junit.xml').write_text('') + (root / '.run-store-lanes.json').write_text(json.dumps({'junit-rust-test-701': 'rust-test (701)'})) + output = root / 'units.tsv' + with patch.object(sys, 'argv', ['junit.py', str(root), str(output), 'rust-test (701)']): + exec(compile(env['JUNIT_PY'], 'junit.py', 'exec'), {}) + self.assertEqual(output.read_text(), 'test\tsuite::broken\trust-test (701)\n') + namespace = {'__name__': 'fixture'} + exec(compile(env['WINDOW_PY'], 'window.py', 'exec'), namespace) + failed, passed, covered = namespace['read_junit'](str(root)) + self.assertEqual(failed, {('suite::broken', 'rust-test (701)')}) + self.assertEqual(passed, {('suite::passing', 'rust-test (701)')}) + self.assertEqual(covered, {'rust-test (701)'}) + + def test_all_red_main_reads_use_trusted_run_store_helper(self): + text = (ROOT / '.github/workflows/red-main.yml').read_text() + self.assertNotIn('"run", "download"', text) + self.assertNotIn('gha run download', text) + self.assertIn('ref: ${{ job.workflow_sha }}', text) + self.assertEqual(text.count('python3 "$RUN_STORE_JUNIT"'), 3) + self.assertIn('env["RUN_STORE_JUNIT"]', text) + producer = (ROOT / 'workflow-templates/optimistic-verify.yml').read_text() + self.assertNotIn('actions/upload-artifact', producer) + self.assertIn('name: run-store put junit-integration', producer) + self.assertIn('id-token: write', producer) + + +if __name__ == '__main__': + unittest.main() diff --git a/workflow-templates/ios-release.yml b/workflow-templates/ios-release.yml index f76f3c8..67b758a 100644 --- a/workflow-templates/ios-release.yml +++ b/workflow-templates/ios-release.yml @@ -12,6 +12,7 @@ on: permissions: contents: read + id-token: write jobs: testflight: diff --git a/workflow-templates/optimistic-verify.yml b/workflow-templates/optimistic-verify.yml index 788bdd1..c0dcb3f 100644 --- a/workflow-templates/optimistic-verify.yml +++ b/workflow-templates/optimistic-verify.yml @@ -39,6 +39,7 @@ permissions: contents: read actions: read checks: read + id-token: write jobs: plan: @@ -67,23 +68,18 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - run: echo "the full test suite; write a JUnit report" - # The red-main handler names flaky tests from `junit-` artifacts. - # The report is a diagnostic: when the organization's artifact storage - # quota is spent the upload fails, and that must never turn a passing - # suite red (docs/run-store.md). Without it the handler falls back to - # naming the lane. - # Keep `path` on the report file: a working tree copied with it, a - # `**/*.bundle` or a blob added here and uploaded spends the whole - # organization's metered quota and holds its CI down. Files another job - # of this run needs are a hand-over - use run-store, never an artifact. - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - if: failure() + # Keep this explicit step name equal to "run-store put ". + # red-main discovers exact names (including matrix shards) from run jobs. + # Diagnostics remain optional; a missing report falls back to lane evidence. + - name: run-store put junit-integration + uses: SylphxAI/.github/.github/actions/run-store@main + if: always() continue-on-error: true with: + mode: put name: junit-integration path: junit.xml - if-no-files-found: ignore - retention-days: 1 + required: "false" e2e: needs: plan diff --git a/workflow-templates/red-main.yml b/workflow-templates/red-main.yml index d3e6954..45833dd 100644 --- a/workflow-templates/red-main.yml +++ b/workflow-templates/red-main.yml @@ -31,6 +31,7 @@ on: permissions: actions: write checks: read + id-token: write contents: read issues: write pull-requests: write