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