Skip to content

Commit

Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'

syntaqxcommitted Parents187276ec5c643bBrowse files
48 files+1630−58616 files not counted0/48 viewed
+2−0
8989 run: npm test --workspaces --if-present
9090 - name: The deploy tool's tests
9191 run: npm run test:deploy
92+ - name: The conformance report's tests
93+ run: npm run test:conformance
9294 - name: The ops scripts' tests
9395 run: npm run test:ops
9496 - name: The manifest matches every wrangler.jsonc
+118−0
1+# The same workflow on GitHub and on g1t: this file and
2+# .github/workflows/conformance.yml are kept identical (a test says so).
3+# Run it by hand on both, then compare what each job saw with
4+# `node scripts/conformance-diff.mjs github.json g1t.json`; see
5+# CONTRIBUTING.md, "Checking g1t against GitHub". It changes nothing,
6+# reads no secrets and never runs on its own.
7+name: Conformance
8+
9+on:
10+ workflow_dispatch:
11+
12+permissions:
13+ contents: read
14+
15+jobs:
16+ first:
17+ name: First
18+ runs-on: ubuntu-latest
19+ timeout-minutes: 10
20+ outputs:
21+ value: ${{ steps.out.outputs.value }}
22+ steps:
23+ - id: out
24+ run: echo "value=hello" >> "$GITHUB_OUTPUT"
25+ - name: Something to cache and to pass on
26+ run: |
27+ mkdir -p conformance-cache conformance-artifact
28+ echo "cached" > conformance-cache/file.txt
29+ echo "passed on" > conformance-artifact/file.txt
30+ - uses: actions/cache/save@v4
31+ with:
32+ path: conformance-cache
33+ key: conformance-${{ github.run_id }}-${{ github.run_attempt }}
34+ - uses: actions/upload-artifact@v4
35+ with:
36+ name: conformance-artifact
37+ path: conformance-artifact
38+ retention-days: 1
39+
40+ report:
41+ name: Report (${{ matrix.n }})
42+ needs: first
43+ runs-on: ubuntu-latest
44+ timeout-minutes: 10
45+ strategy:
46+ matrix:
47+ n: [1]
48+ steps:
49+ - uses: actions/checkout@v5
50+ - name: Environment and path for later steps
51+ run: |
52+ echo "FROM_ENV=set" >> "$GITHUB_ENV"
53+ mkdir -p "$RUNNER_TEMP/conformance-bin"
54+ printf '#!/bin/sh\necho on-path\n' > "$RUNNER_TEMP/conformance-bin/conformance-tool"
55+ chmod +x "$RUNNER_TEMP/conformance-bin/conformance-tool"
56+ echo "$RUNNER_TEMP/conformance-bin" >> "$GITHUB_PATH"
57+ - id: later
58+ name: What a later step sees
59+ run: |
60+ echo "tool=$(conformance-tool)" >> "$GITHUB_OUTPUT"
61+ {
62+ echo "text<<CONFORMANCE_EOF"
63+ echo "line one"
64+ echo "line two"
65+ echo "CONFORMANCE_EOF"
66+ } >> "$GITHUB_OUTPUT"
67+ - id: folder
68+ working-directory: conformance
69+ run: echo "name=$(basename "$PWD")" >> "$GITHUB_OUTPUT"
70+ - id: restore
71+ uses: actions/cache/restore@v4
72+ with:
73+ path: conformance-cache
74+ key: conformance-${{ github.run_id }}-${{ github.run_attempt }}
75+ - uses: actions/download-artifact@v4
76+ with:
77+ name: conformance-artifact
78+ path: downloaded
79+ - id: files
80+ run: |
81+ echo "cached=$(cat conformance-cache/file.txt 2>/dev/null)" >> "$GITHUB_OUTPUT"
82+ echo "artifact=$(cat downloaded/file.txt 2>/dev/null)" >> "$GITHUB_OUTPUT"
83+ - name: Report
84+ env:
85+ CTX_GITHUB: ${{ toJSON(github) }}
86+ CTX_RUNNER: ${{ toJSON(runner) }}
87+ CTX_JOB: ${{ toJSON(job) }}
88+ CTX_STRATEGY: ${{ toJSON(strategy) }}
89+ CTX_MATRIX: ${{ toJSON(matrix) }}
90+ CTX_STEPS: ${{ toJSON(steps) }}
91+ CTX_NEEDS: ${{ toJSON(needs) }}
92+ CHECK_NEEDS_OUTPUT: ${{ needs.first.outputs.value }}
93+ CHECK_ENV_FROM_FILE: ${{ env.FROM_ENV }}
94+ CHECK_PATH_FROM_FILE: ${{ steps.later.outputs.tool }}
95+ CHECK_MULTILINE_OUTPUT: ${{ steps.later.outputs.text }}
96+ CHECK_WORKING_DIRECTORY: ${{ steps.folder.outputs.name }}
97+ CHECK_CACHE_HIT: ${{ steps.restore.outputs.cache-hit }}
98+ CHECK_CACHE_CONTENT: ${{ steps.files.outputs.cached }}
99+ CHECK_ARTIFACT_CONTENT: ${{ steps.files.outputs.artifact }}
100+ CHECK_FORMAT: ${{ format('{0}-{1}-{{2}}', 'a', 1) }}
101+ CHECK_CONTAINS: ${{ contains(fromJSON('["a","b"]'), 'b') }}
102+ CHECK_STARTS_WITH: ${{ startsWith('Hello', 'he') }}
103+ CHECK_JOIN: ${{ join(fromJSON('["a","b","c"]'), '+') }}
104+ CHECK_TO_JSON: ${{ toJSON(fromJSON('{"a":[1,2],"b":null}')) }}
105+ CHECK_EQUALITY: ${{ 1 == '1' }} ${{ 'A' == 'a' }} ${{ null == '' }}
106+ CHECK_FALSY_OR: ${{ '' || 'fallback' }}
107+ CHECK_HASH_FILES: ${{ hashFiles('conformance/report.mjs') }}
108+ CHECK_SUCCESS: ${{ success() }}
109+ CHECK_MATRIX: ${{ matrix.n }}
110+ CHECK_JOB_TOTAL: ${{ strategy.job-total }}
111+ CHECK_EVENT_NAME: ${{ github.event_name }}
112+ CHECK_RUNNER_OS: ${{ runner.os }}
113+ run: node conformance/report.mjs conformance.json
114+ - uses: actions/upload-artifact@v4
115+ with:
116+ name: conformance-report
117+ path: conformance.json
118+ retention-days: 7
+118−0
1+# The same workflow on GitHub and on g1t: this file and
2+# .github/workflows/conformance.yml are kept identical (a test says so).
3+# Run it by hand on both, then compare what each job saw with
4+# `node scripts/conformance-diff.mjs github.json g1t.json`; see
5+# CONTRIBUTING.md, "Checking g1t against GitHub". It changes nothing,
6+# reads no secrets and never runs on its own.
7+name: Conformance
8+
9+on:
10+ workflow_dispatch:
11+
12+permissions:
13+ contents: read
14+
15+jobs:
16+ first:
17+ name: First
18+ runs-on: ubuntu-latest
19+ timeout-minutes: 10
20+ outputs:
21+ value: ${{ steps.out.outputs.value }}
22+ steps:
23+ - id: out
24+ run: echo "value=hello" >> "$GITHUB_OUTPUT"
25+ - name: Something to cache and to pass on
26+ run: |
27+ mkdir -p conformance-cache conformance-artifact
28+ echo "cached" > conformance-cache/file.txt
29+ echo "passed on" > conformance-artifact/file.txt
30+ - uses: actions/cache/save@v4
31+ with:
32+ path: conformance-cache
33+ key: conformance-${{ github.run_id }}-${{ github.run_attempt }}
34+ - uses: actions/upload-artifact@v4
35+ with:
36+ name: conformance-artifact
37+ path: conformance-artifact
38+ retention-days: 1
39+
40+ report:
41+ name: Report (${{ matrix.n }})
42+ needs: first
43+ runs-on: ubuntu-latest
44+ timeout-minutes: 10
45+ strategy:
46+ matrix:
47+ n: [1]
48+ steps:
49+ - uses: actions/checkout@v5
50+ - name: Environment and path for later steps
51+ run: |
52+ echo "FROM_ENV=set" >> "$GITHUB_ENV"
53+ mkdir -p "$RUNNER_TEMP/conformance-bin"
54+ printf '#!/bin/sh\necho on-path\n' > "$RUNNER_TEMP/conformance-bin/conformance-tool"
55+ chmod +x "$RUNNER_TEMP/conformance-bin/conformance-tool"
56+ echo "$RUNNER_TEMP/conformance-bin" >> "$GITHUB_PATH"
57+ - id: later
58+ name: What a later step sees
59+ run: |
60+ echo "tool=$(conformance-tool)" >> "$GITHUB_OUTPUT"
61+ {
62+ echo "text<<CONFORMANCE_EOF"
63+ echo "line one"
64+ echo "line two"
65+ echo "CONFORMANCE_EOF"
66+ } >> "$GITHUB_OUTPUT"
67+ - id: folder
68+ working-directory: conformance
69+ run: echo "name=$(basename "$PWD")" >> "$GITHUB_OUTPUT"
70+ - id: restore
71+ uses: actions/cache/restore@v4
72+ with:
73+ path: conformance-cache
74+ key: conformance-${{ github.run_id }}-${{ github.run_attempt }}
75+ - uses: actions/download-artifact@v4
76+ with:
77+ name: conformance-artifact
78+ path: downloaded
79+ - id: files
80+ run: |
81+ echo "cached=$(cat conformance-cache/file.txt 2>/dev/null)" >> "$GITHUB_OUTPUT"
82+ echo "artifact=$(cat downloaded/file.txt 2>/dev/null)" >> "$GITHUB_OUTPUT"
83+ - name: Report
84+ env:
85+ CTX_GITHUB: ${{ toJSON(github) }}
86+ CTX_RUNNER: ${{ toJSON(runner) }}
87+ CTX_JOB: ${{ toJSON(job) }}
88+ CTX_STRATEGY: ${{ toJSON(strategy) }}
89+ CTX_MATRIX: ${{ toJSON(matrix) }}
90+ CTX_STEPS: ${{ toJSON(steps) }}
91+ CTX_NEEDS: ${{ toJSON(needs) }}
92+ CHECK_NEEDS_OUTPUT: ${{ needs.first.outputs.value }}
93+ CHECK_ENV_FROM_FILE: ${{ env.FROM_ENV }}
94+ CHECK_PATH_FROM_FILE: ${{ steps.later.outputs.tool }}
95+ CHECK_MULTILINE_OUTPUT: ${{ steps.later.outputs.text }}
96+ CHECK_WORKING_DIRECTORY: ${{ steps.folder.outputs.name }}
97+ CHECK_CACHE_HIT: ${{ steps.restore.outputs.cache-hit }}
98+ CHECK_CACHE_CONTENT: ${{ steps.files.outputs.cached }}
99+ CHECK_ARTIFACT_CONTENT: ${{ steps.files.outputs.artifact }}
100+ CHECK_FORMAT: ${{ format('{0}-{1}-{{2}}', 'a', 1) }}
101+ CHECK_CONTAINS: ${{ contains(fromJSON('["a","b"]'), 'b') }}
102+ CHECK_STARTS_WITH: ${{ startsWith('Hello', 'he') }}
103+ CHECK_JOIN: ${{ join(fromJSON('["a","b","c"]'), '+') }}
104+ CHECK_TO_JSON: ${{ toJSON(fromJSON('{"a":[1,2],"b":null}')) }}
105+ CHECK_EQUALITY: ${{ 1 == '1' }} ${{ 'A' == 'a' }} ${{ null == '' }}
106+ CHECK_FALSY_OR: ${{ '' || 'fallback' }}
107+ CHECK_HASH_FILES: ${{ hashFiles('conformance/report.mjs') }}
108+ CHECK_SUCCESS: ${{ success() }}
109+ CHECK_MATRIX: ${{ matrix.n }}
110+ CHECK_JOB_TOTAL: ${{ strategy.job-total }}
111+ CHECK_EVENT_NAME: ${{ github.event_name }}
112+ CHECK_RUNNER_OS: ${{ runner.os }}
113+ run: node conformance/report.mjs conformance.json
114+ - uses: actions/upload-artifact@v4
115+ with:
116+ name: conformance-report
117+ path: conformance.json
118+ retention-days: 7
+28−0
7979 - Look at what you changed in a browser. Screenshots catch what type
8080 checks do not.
8181
82+## Checking g1t against GitHub
83+
84+g1t runs GitHub Actions workflows as GitHub does. The Conformance
85+workflow is how that is checked: `.g1t/workflows/conformance.yml` and
86+`.github/workflows/conformance.yml` are the same file (a test in
87+`crates/actions` fails when they differ), and each run's
88+`conformance-report` artifact records what its jobs saw: which `GITHUB_*`
89+and `RUNNER_*` variables were set and of what kind, every key of the
90+contexts and the event and its type, how the workspace is laid out, and
91+the values of outputs, files, caches, artifacts and expressions that
92+should be the same everywhere. It reads no secrets and runs only by hand.
93+
94+1. Run it on both, at the same commit: **Actions → Conformance → Run
95+ workflow** on GitHub and on g1t.sh.
96+2. Download each run's `conformance-report` artifact, and name the two
97+ files `github.json` and `g1t.json`.
98+3. Compare them:
99+
100+ ```sh
101+ node scripts/conformance-diff.mjs github.json g1t.json
102+ ```
103+
104+It lists what GitHub has that g1t lacks or has otherwise, then what only
105+g1t has, and exits 1 while there are differences. A difference is a bug in
106+g1t, or belongs in [What g1t can't do yet](https://docs.g1t.sh/about/limitations/).
107+Add a step and a `CHECK_` value to the workflow for anything new g1t
108+should match.
109+
82110 ## Deploying
83111
84112 Pushes to `main` deploy themselves: `.g1t/workflows/deploy.yml` runs
+97−3
8181 ]
8282
8383 [[package]]
84+name = "ahash"
85+version = "0.8.12"
86+source = "registry+https://github.com/rust-lang/crates.io-index"
87+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
88+dependencies = [
89+ "cfg-if",
90+ "once_cell",
91+ "version_check",
92+ "zerocopy",
93+]
94+
95+[[package]]
8496 name = "aho-corasick"
8597 version = "1.1.5"
8698 source = "registry+https://github.com/rust-lang/crates.io-index"
740752 ]
741753
742754 [[package]]
755+name = "fallible-iterator"
756+version = "0.3.0"
757+source = "registry+https://github.com/rust-lang/crates.io-index"
758+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
759+
760+[[package]]
761+name = "fallible-streaming-iterator"
762+version = "0.1.9"
763+source = "registry+https://github.com/rust-lang/crates.io-index"
764+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
765+
766+[[package]]
743767 name = "ff"
744768 version = "0.14.0"
745769 source = "registry+https://github.com/rust-lang/crates.io-index"
930954 "g1t-kit",
931955 "g1t-secrets",
932956 "hex",
957+ "rusqlite",
933958 "serde",
934959 "serde_json",
935960 "worker",
13151340
13161341 [[package]]
13171342 name = "hashbrown"
1343+version = "0.14.5"
1344+source = "registry+https://github.com/rust-lang/crates.io-index"
1345+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
1346+dependencies = [
1347+ "ahash",
1348+]
1349+
1350+[[package]]
1351+name = "hashbrown"
13181352 version = "0.17.1"
13191353 source = "registry+https://github.com/rust-lang/crates.io-index"
13201354 checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
13241358
13251359 [[package]]
13261360 name = "hashlink"
1361+version = "0.9.1"
1362+source = "registry+https://github.com/rust-lang/crates.io-index"
1363+checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
1364+dependencies = [
1365+ "hashbrown 0.14.5",
1366+]
1367+
1368+[[package]]
1369+name = "hashlink"
13271370 version = "0.12.2"
13281371 source = "registry+https://github.com/rust-lang/crates.io-index"
13291372 checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
13301373 dependencies = [
1331− "hashbrown",
1374+ "hashbrown 0.17.1",
13321375 ]
13331376
13341377 [[package]]
16241667 checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
16251668 dependencies = [
16261669 "equivalent",
1627− "hashbrown",
1670+ "hashbrown 0.17.1",
16281671 ]
16291672
16301673 [[package]]
17551798 checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
17561799
17571800 [[package]]
1801+name = "libsqlite3-sys"
1802+version = "0.30.1"
1803+source = "registry+https://github.com/rust-lang/crates.io-index"
1804+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
1805+dependencies = [
1806+ "cc",
1807+ "pkg-config",
1808+ "vcpkg",
1809+]
1810+
1811+[[package]]
17581812 name = "litemap"
17591813 version = "0.8.3"
17601814 source = "registry+https://github.com/rust-lang/crates.io-index"
24442498 ]
24452499
24462500 [[package]]
2501+name = "rusqlite"
2502+version = "0.32.1"
2503+source = "registry+https://github.com/rust-lang/crates.io-index"
2504+checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
2505+dependencies = [
2506+ "bitflags",
2507+ "fallible-iterator",
2508+ "fallible-streaming-iterator",
2509+ "hashlink 0.9.1",
2510+ "libsqlite3-sys",
2511+ "smallvec",
2512+]
2513+
2514+[[package]]
24472515 name = "russh"
24482516 version = "0.63.3"
24492517 source = "registry+https://github.com/rust-lang/crates.io-index"
35023570 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
35033571
35043572 [[package]]
3573+name = "vcpkg"
3574+version = "0.2.15"
3575+source = "registry+https://github.com/rust-lang/crates.io-index"
3576+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
3577+
3578+[[package]]
35053579 name = "version_check"
35063580 version = "0.9.5"
35073581 source = "registry+https://github.com/rust-lang/crates.io-index"
39664040 checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
39674041 dependencies = [
39684042 "arraydeque",
3969− "hashlink",
4043+ "hashlink 0.12.2",
39704044 ]
39714045
39724046 [[package]]
39934067 ]
39944068
39954069 [[package]]
4070+name = "zerocopy"
4071+version = "0.8.62"
4072+source = "registry+https://github.com/rust-lang/crates.io-index"
4073+checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d"
4074+dependencies = [
4075+ "zerocopy-derive",
4076+]
4077+
4078+[[package]]
4079+name = "zerocopy-derive"
4080+version = "0.8.62"
4081+source = "registry+https://github.com/rust-lang/crates.io-index"
4082+checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a"
4083+dependencies = [
4084+ "proc-macro2",
4085+ "quote",
4086+ "syn 2.0.119",
4087+]
4088+
4089+[[package]]
39964090 name = "zerofrom"
39974091 version = "0.1.8"
39984092 source = "registry+https://github.com/rust-lang/crates.io-index"
+5−8
127127 request.json().await.unwrap_or(Value::Null)
128128 }
129129
130−/// Who a request's `Authorization: Bearer g1t_…` names. A missing token is
131−/// an anonymous viewer; a wrong one is refused, so that a typo does not
132−/// silently look signed out.
130+/// Who a request's `Authorization: Bearer g1t_…` (or `token g1t_…`, as
131+/// Octokit sends it) names. A missing token is an anonymous viewer; a
132+/// wrong one is refused, so that a typo does not silently look signed out.
133133 async fn authenticate(
134134 request: &Request,
135135 services: &Services,
136136 ) -> Result<std::result::Result<Viewer, Response>> {
137137 let header = request.headers().get("authorization")?.unwrap_or_default();
138− let token = match header.split_once(' ') {
139− Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") && !token.is_empty() => {
140− token.trim()
141− }
142− _ => return Ok(Ok(None)),
138+ let Some(token) = limits::bearer(&header) else {
139+ return Ok(Ok(None));
143140 };
144141 let viewer: Viewer = g1t_kit::call(
145142 &services.identity,
+12−3
5555 Sha256::digest(token.as_bytes())[..8].iter().map(|byte| format!("{byte:02x}")).collect()
5656 }
5757
58−/// The bearer token of an `Authorization` header, if it has one.
58+/// The access token of an `Authorization` header, if it has one: `Bearer
59+/// <token>`, or `token <token>`, the form GitHub also takes and Octokit
60+/// (`@actions/github`, so `actions/checkout` and `actions/github-script`)
61+/// sends.
5962 pub fn bearer(header: &str) -> Option<&str> {
6063 match header.split_once(' ') {
61− Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") => Some(token.trim()).filter(|t| !t.is_empty()),
64+ Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") || scheme.eq_ignore_ascii_case("token") => {
65+ Some(token.trim()).filter(|t| !t.is_empty())
66+ }
6267 _ => None,
6368 }
6469 }
133138 }
134139
135140 #[test]
136− fn only_bearer_tokens_are_read() {
141+ fn bearer_and_token_schemes_are_read() {
137142 assert_eq!(bearer("Bearer g1t_abc "), Some("g1t_abc"));
138143 assert_eq!(bearer("bearer g1t_abc"), Some("g1t_abc"));
144+ // Octokit's form, as `actions/checkout` sends it for a default branch.
145+ assert_eq!(bearer("token g1t_abc"), Some("g1t_abc"));
146+ assert_eq!(bearer("Token g1t_abc"), Some("g1t_abc"));
147+ assert_eq!(bearer("token "), None);
139148 assert_eq!(bearer("Basic dXNlcjpwYXNz"), None);
140149 assert_eq!(bearer("Bearer "), None);
141150 assert_eq!(bearer(""), None);
+13−2
273273 register with `--no-job-docker`.
274274 - **Status.** Not scheduled.
275275
276+### `actions/checkout` without git, or with Git LFS
277+
278+`actions/checkout` is the action itself. In a job container whose image
279+has no git, it downloads the repository as a tarball from
280+`GET /repos/{owner}/{repo}/tarball/{ref}`, which g1t does not serve yet,
281+and fails. With `lfs: true`, it finds no LFS files: g1t keeps none.
282+
283+- **Instead.** Use an image with git, or install git in a step before the
284+ checkout. Keep large files out of Git LFS for now.
285+- **Status.** Planned.
286+
276287 ### No `gh` command in jobs
277288
278289 The runner does not include the `gh` command, and pointing it at g1t
318329 library themselves. The library refuses to run against any server but
319330 github.com. `actions/upload-artifact`, `actions/download-artifact` and
320331 `actions/upload-artifact/merge` work, as g1t runs them itself.
321−- A cache entry between 100 and 128 MB saved by an action built on the
322− toolkit, such as `setup-node` with `cache: npm`. The toolkit sends an
332+- A cache entry between 100 and 128 MB saved by `actions/cache` or another
333+ action built on the toolkit, such as `setup-node` with `cache: npm`. The toolkit sends an
323334 entry under 128 MB in one request, and g1t takes at most 100 MB in one
324335 request, as for [pushes](#pushes-up-to-100-mb-each). The step warns and
325336 the job goes on; smaller and larger entries are saved.
+117−17
6666 | JavaScript actions (`uses: owner/repo@v7`, `owner/repo/path@v7`) | Fetched from that repository on g1t when g1t has it and your repository may use it, otherwise from GitHub, and run as they are, on Node 24, the runtime current actions declare. An action that declares `node20` runs on Node 20 where the machine has one. See [actions and workflows from other repositories](#actions-and-workflows-from-other-repositories) and [an action's pre and post](#an-actions-pre-and-post). |
6767 | Composite actions | The same. |
6868 | Reusable workflows (`jobs.<id>.uses: ./.g1t/workflows/build.yml`, or `owner/repo/.g1t/workflows/build.yml@v1` in another repository) | The same: `with:` inputs checked against their `type`, `secrets:` by name or `secrets: inherit`, `on.workflow_call` outputs, `strategy.matrix` on the calling job (one call for each combination), `concurrency` on the calling job, and up to ten levels of workflows with at most 50 different reusable workflows in a run. `.github/workflows/…` finds the workflow under `.g1t/` after the move. See [actions and workflows from other repositories](#actions-and-workflows-from-other-repositories). |
69−| `actions/checkout` | Checks out from g1t, with `ref`, `fetch-depth`, `path`, `repository`, `token` and `submodules`. |
69+| `actions/checkout` | The action itself, at the version the step names, fetching from g1t with the job's token: every input and output, its post step that removes the credential, and a pull request's [test merge commit](#what-a-pull-requests-run-builds), which g1t keeps in the repository. `lfs` finds no Git LFS files: g1t keeps none. |
7070 | `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | The same. Step summaries show on the run's page; see [job summaries](#job-summaries). |
7171 | `::error::`, `::warning::`, `::notice::`, `::group::`, `::add-mask::`, `::echo::on` | The same: errors and warnings become annotations on the run, with their `file`, `line`, `col`, `endLine` and `endColumn`, and [masked](#masking-secrets) values stay hidden. |
7272 | Problem matchers (`::add-matcher::`, `::remove-matcher::`) | The same, so `actions/setup-node`, `setup-go` and `setup-python` turn compiler and linter output into annotations. See [problem matchers](#problem-matchers). |
7373 | `secrets.*`, `vars.*`, `secrets.GITHUB_TOKEN` | The same. `secrets.G1T_TOKEN` is [the job's own token](#the-jobs-token); `GITHUB_TOKEN` is its alias. |
7474 | `environment:` on a job | The job waits for the environment's [protection rules](#environments), then reads each key's row for that environment, as environment secrets work, and the run records a [deployment](/guides/deployments-api/#deployments-from-g1t-actions) to it. `url` gives the deployment its address; `deployment: false` reads the environment's values without making one. The name may be an expression. |
7575 | `actions/upload-artifact`, `actions/download-artifact`, `actions/upload-artifact/merge` | The same inputs and outputs as version 4: `retention-days`, `overwrite`, `compression-level`, `include-hidden-files`, `!` exclusions, download by `pattern` with `merge-multiple`, and from another run with `run-id` and `github-token`. Up to 5 GiB each; see [artifacts](#artifacts). |
76−| `actions/cache`, `actions/cache/restore`, `actions/cache/save` | Kept per repository and branch, found by `key` or the newest under a `restore-keys` prefix. `path` takes globs and `!` exclusions. Up to 2 GiB each; see [the cache](#the-cache). |
76+| `actions/cache`, `actions/cache/restore`, `actions/cache/save` | The actions themselves, at the version the step names, saving to and restoring from the repository's cache: kept per repository and branch, found by `key` or the newest under a `restore-keys` prefix. Up to 2 GiB each; see [the cache](#the-cache). |
7777 | Actions that cache through the toolkit, such as `actions/setup-node` with `cache: npm` or `Swatinem/rust-cache` | The same: they save to and restore from the repository's cache. See [actions built on the toolkit](#actions-built-on-the-toolkit). |
7878 | sccache with `SCCACHE_GHA_ENABLED` | The same: its entries go to the repository's cache. See [caching Rust builds](#caching-rust-builds). |
7979 | `permissions: id-token: write` | The job can ask for an OIDC token, and trade it for a cloud provider's credentials. See [OIDC tokens](#oidc-tokens). |
105105 the `pull_request` types `unassigned`, `locked` and `unlocked`, `edited`
106106 for a new title or description, or the `issues` types `unassigned`,
107107 `locked`, `unlocked`, `pinned` and `unpinned`.
108+- **`actions/checkout` in a job container without git.** The action
109+ then downloads a tarball through the API, which g1t does not serve yet.
110+ Use an image with git, or install it in an earlier step.
108111 - **Background steps.** A step with `background: true`, or a `wait`,
109112 `wait-all`, `cancel` or `parallel` step, is refused: g1t does not run
110113 them yet.
243246 GitHub, a tag before a branch of the same name, and looks again at
244247 most every 5 minutes, so a tag that moves is followed within minutes.
245248 3. The first job to use an action at a commit has g1t fetch that commit's
246− archive from GitHub and keep it. Every later job, in any workspace,
247− downloads the same bytes from g1t.
249+ archive from GitHub and keep it, which can take up to two minutes.
250+ While it waits, **Set up job** says so every 5 seconds
251+ (`Waiting for g1t to fetch 'actions/setup-node@v4' (10s)`). Every
252+ later job, in any workspace, downloads the same bytes from g1t.
248253
249254 While GitHub doesn't answer, a ref reads as the commit it last named, and
250255 the job says so in a warning; an action g1t keeps still downloads. An
613618 language images have both). In an image without them, such as Alpine,
614619 they run beside the container, on g1t's runner, with the same files,
615620 and the log says so.
616−- `actions/checkout`, `actions/cache` and the artifact actions run on
617− g1t's runner, with the same files.
621+- The artifact actions run on g1t's runner, with the same files.
618622
619623 ### Building and pushing images
620624
783787
784788 ## Actions built on the toolkit
785789
786−Many actions save to the cache with GitHub's toolkit, `@actions/cache`,
787−rather than through `actions/cache`: `actions/setup-node`,
788−`actions/setup-python`, `actions/setup-go` and `actions/setup-java` with
789−`cache:`, `Swatinem/rust-cache`, and others. They work on g1t as they
790−are: every job gets `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_CACHE_URL` and
790+`actions/cache` and many other actions save to the cache with GitHub's
791+toolkit, `@actions/cache`: `actions/setup-node`, `actions/setup-python`,
792+`actions/setup-go` and `actions/setup-java` with `cache:`,
793+`Swatinem/rust-cache`, and others. They work on g1t as they are: every job gets `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_CACHE_URL` and
791794 `ACTIONS_RESULTS_URL`, and g1t answers the toolkit's requests from the
792795 repository's cache.
793796
794797 - Their entries are the repository's, under [the cache's](#the-cache)
795798 limits, and are deleted the same way.
796−- An entry is restored only by the same kind of save: the toolkit names a
797− version for each entry, from its paths and compression. An entry
798− `setup-node` saved is not restored by `actions/cache`, and the other way
799− round.
799+- An entry is found by its key and by the version the toolkit names for
800+ it, from its paths and compression, as on GitHub: an entry `setup-node`
801+ saved is restored by `actions/cache` with the same key and paths, and
802+ the other way round.
800803 - An entry the toolkit sends whole, which it does below 128 MB, is not
801804 saved when it is over 100 MB, the most g1t takes in one request. The
802805 step warns and the job goes on.
990993 | --- | --- |
991994 | `sub` | `repo:acme/web:environment:production` for a job with an `environment:`; `repo:acme/web:pull_request` for a pull request's run; otherwise `repo:acme/web:ref:refs/heads/main` (or `refs/tags/v1.2.0`) |
992995 | `repository`, `repository_owner` | `acme/web`, `acme` |
993−| `repository_id`, `repository_owner_id` | g1t's ids for them, such as `rep_01kpw0…` |
996+| `repository_id`, `repository_owner_id` | g1t's ids for them, such as `rep_01kpw0…` (not the numbers `github.repository_id` and `github.repository_owner_id` give, so trust policies already written keep matching) |
994997 | `repository_visibility` | `public` or `private` |
995998 | `ref`, `ref_type`, `ref_protected`, `sha` | `refs/heads/main`, `branch`, `"true"`, the commit |
996999 | `head_ref`, `base_ref` | A pull request's branches |
11671170 logs** shows or hides the times; your browser remembers which.
11681171 - A download (the job's `log.txt` and the run's zip) has the time at the
11691172 start of every line, as GitHub's downloaded logs do.
1173+- Each line's number links to it, as on GitHub: `#step:3:12` is line 12
1174+ of step 3 (0 is **Set up job**). Opening the link opens the step and
1175+ any group the line is in, scrolls to the line and marks it.
1176+- A long step's log stays fast. Each new piece of a running step's log is
1177+ read once as it arrives, and a step of more than 2,000 lines draws only
1178+ the lines on screen and a few around them. Search, groups, line links,
1179+ following the newest line and **Jump to latest** work as they do on a
1180+ short log. Your browser's own find (Ctrl+F) only sees the lines that are
1181+ drawn, so use **Search logs** in a long log.
1182+- The runner numbers each batch of the log it sends. When it sends a
1183+ batch again because g1t's answer didn't reach it, g1t keeps the batch
1184+ once, so a line never shows twice.
11701185 - If the page cannot reach g1t for a moment, it says **Reconnecting to the
11711186 log** and tries again, waiting longer each time up to half a minute.
11721187 A tab you are not looking at stops asking, and catches up when you come
13971412 always the default branch: see
13981413 [pull requests into other branches](/guides/base-branches/).
13991414
1415+### What a pull request's run builds
1416+
1417+As on GitHub, a `pull_request` run builds the pull request merged into
1418+its base branch, not its head alone:
1419+
1420+| | Is |
1421+| --- | --- |
1422+| `github.ref`, `GITHUB_REF` | `refs/pull/<number>/merge` |
1423+| `github.sha`, `GITHUB_SHA` | The test merge commit at `refs/pull/<number>/merge`: the head merged into the base branch's latest commit, its parents the base's commit and then the head, with the message `Merge <head> into <base>` |
1424+| `github.event.pull_request.head.sha` | The head commit |
1425+| `github.event.pull_request.merge_commit_sha` | The test merge commit |
1426+| `actions/checkout` with no `ref` | The test merge commit, fetched from the repository itself as `+<sha>:refs/remotes/pull/<number>/merge` |
1427+
1428+The workflow files the run uses are read from the test merge commit too.
1429+Its status, its check runs and the run itself stay on the pull request's
1430+head, so required checks hold the merge as before. `pull_request_review`
1431+runs build the same commit. `pull_request_target` runs are unchanged: they
1432+build the base branch, never the pull request's code.
1433+
1434+g1t keeps two refs in the repository for every open pull request, from a
1435+branch, a fork or a pull request g1t made: `refs/pull/<number>/head` at
1436+its head (a fork's head commit is copied into the repository), and
1437+`refs/pull/<number>/merge` at its test merge commit. Both move each time
1438+the head is pushed to, the pull request is opened or reopened, or its
1439+base branch changes, and both can be fetched like any branch:
1440+
1441+```sh
1442+git fetch origin refs/pull/12/head refs/pull/12/merge
1443+```
1444+
1445+The merge commit moves when the head does. When only the base branch
1446+moves, it moves at the pull request's next event, as on GitHub, where
1447+workflows do not run again because the base branch moved.
1448+
1449+**A pull request with conflicts runs no `pull_request` workflows**, as on
1450+GitHub: it has no merge commit to build. Its checks box says so, and the
1451+runs start once the conflicts are resolved and the head is pushed again.
1452+g1t merges the two the way git does: a file only one side changed is
1453+taken from it, and changes both made to the same file merge unless they
1454+change the same or neighbouring lines differently, or the file is binary.
1455+When g1t cannot make the merge itself (a file one side renamed or deleted
1456+while the other changed it, a file where the other side has a directory,
1457+more than 300 changed files, or a file over 1 MB that both changed), the
1458+run builds the head commit instead, as it did before.
1459+
14001460 A pull request from outside the workspace may wait for approval before
14011461 its workflows run: see [pull requests from outside](#pull-requests-from-outside).
14021462
14061466 on anyone else's. `github.event.issue.requested_by` does the same for an
14071467 issue g1t's agent filed. `sender` is whoever caused the event.
14081468
1469+## The event payload
1470+
1471+`github.event`, and the file at `GITHUB_EVENT_PATH`, hold the webhook
1472+payload GitHub would send for the event, with the same keys and types.
1473+
1474+- **Ids are numbers.** `repository.id`, `pull_request.id`, `issue.id`,
1475+ `review.id`, `sender.id`, every `user.id` and `owner.id` are whole
1476+ numbers, the same for an object wherever it appears and below 2^53, so
1477+ JavaScript reads them exactly. `node_id` is g1t's own id, such as
1478+ `rep_01kpw0…`. A person's or workspace's number comes from their name,
1479+ so it changes when they are renamed. `github.repository_id`,
1480+ `github.repository_owner_id`, `github.actor_id` and their
1481+ `GITHUB_REPOSITORY_ID`, `GITHUB_REPOSITORY_OWNER_ID` and
1482+ `GITHUB_ACTOR_ID` variables are the same numbers, as text.
1483+- **What the API takes by id keeps g1t's id.** `comment.id`,
1484+ `release.id`, `deployment.id`, `deployment_status.id`,
1485+ `workflow_run.id` and `workflow_run.workflow_id` are g1t's ids
1486+ (`cmt_…`, `rel_…`, `run_…`, `wfl_…`), so you can pass them straight
1487+ back, as in `PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}`
1488+ or `run-id: ${{ github.event.workflow_run.id }}`.
1489+- **`repository.owner.type`** is `User` for a repository in a person's
1490+ own workspace and `Organization` for any other workspace's.
1491+- **On `push`,** `created` is true for a new branch or tag, and `forced`
1492+ is true when the commit before the push is not in the new commit's
1493+ history. g1t reads the 20 latest commits to tell, so a forced push onto
1494+ a longer history says `false`. `head_commit` is the commit pushed and
1495+ `commits` lists at most 20, oldest first.
1496+- **On pull request events,** `pull_request.base.sha` is the tip of the
1497+ branch it merges into when the event happened, and
1498+ `pull_request.merge_commit_sha` is set only once it is merged, to the
1499+ commit it landed as.
1500+- **On `workflow_run`,** `workflow_run` has the followed run's `name`,
1501+ `path`, `event`, `status`, `conclusion`, `head_branch`, `head_sha`,
1502+ `head_commit`, `run_number`, `run_attempt`, `actor`, `created_at`,
1503+ `updated_at`, `html_url`, `repository` and `pull_requests` (each with
1504+ its `number` and `url`).
1505+
1506+Labels on `issue` and `pull_request` have their `id` and `name` but no
1507+`color`; a `labeled` event's own `label` has its color.
1508+
14091509 ## Checks
14101510
14111511 A pull request's checks are its workflows. Each workflow that runs on
1412−`pull_request` runs on every pull request's head, whoever opened it, a
1512+`pull_request` runs for every pull request, whoever opened it, a
14131513 person or an agent, and its runs report a check named after the workflow:
14141514 a workflow with `name: CI` reports `CI`, with the status context
14151515 `CI / pull_request` (the workflow's name and the event). Each of its jobs
+5−0
208208 -d "{\"name\": \"coverage\", \"head_sha\": \"$GITHUB_SHA\", \"conclusion\": \"neutral\", \"output\": {\"title\": \"81% covered\", \"summary\": \"Up 2% from main.\"}}"
209209 ```
210210
211+On a pull request's run, `GITHUB_SHA` is its
212+[test merge commit](/guides/actions/#what-a-pull-requests-run-builds), as on
213+GitHub: to show the check on the pull request, report it on the head,
214+`${{ github.event.pull_request.head.sha || github.sha }}`.
215+
211216 A pull request's runs from someone without the Write role get no token
212217 that can write, so they cannot report checks.
213218
+4−0
4747 -H "Authorization: Bearer $G1T_TOKEN"
4848 ```
4949
50+`Authorization: token $G1T_TOKEN`, the other form GitHub takes, works the
51+same, so Octokit and the actions built on it, such as `actions/checkout`
52+and `actions/github-script`, are signed in with the token they are given.
53+
5054 Public data can be read without a token. A token that is not valid is
5155 rejected with `401` rather than treated as anonymous. Each endpoint's page
5256 says whether it needs a token.
+195−54
44 * its errors and warnings marked.
55 */
66 import { AlertTriangle, Ban, Check, ChevronRight, CircleDashed, CircleSlash, Clock, Hourglass, Info, Loader2, ShieldAlert, X } from "lucide-react";
7−import { Fragment, memo, useEffect, useMemo, useRef, useState } from "react";
7+import { Fragment, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
88 import { Link } from "react-router";
99
1010 import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts";
1212 import { Card } from "./ui/card";
1313 import { Hint } from "./ui/hint";
1414 import { markSpans, parseAnsi, spanLook } from "../lib/ansi";
15−import { type Line, blocks, searchLog } from "../lib/log-lines";
15+import { type Line, type Row, type StepLog, appendLog, emptyLog, groupOf, logRows, rowKey, searchStepLog } from "../lib/log-lines";
16+import { WINDOW_FROM, rowStarts, windowOf } from "../lib/log-window";
1617 import { type PollAnswer, nextPoll, retryAfterMs } from "../lib/log-poll";
1718
1819 /**
147148 );
148149 }
149150
150−/** One line of a step's log: its number, its time when asked for, and its text. Drawn again only when it changes. */
151−type LogLineProps = { line: Line; number: number; query?: string; timestamps?: boolean };
151+/** One line of a step's log: its number (a link to it), its time when asked for, and its text. Drawn again only when it changes. */
152+type LogLineProps = { line: Line; number: number; query?: string; timestamps?: boolean; anchor?: string; targeted?: boolean };
152153
153−const LogLine = memo(function LogLine({ line, number, query = "", timestamps = false }: LogLineProps) {
154+const LogLine = memo(function LogLine({ line, number, query = "", timestamps = false, anchor, targeted = false }: LogLineProps) {
154155 const label = /^(Error|Warning|Notice): /;
155156 const text = line.text.replace(label, "");
156157 const ansi = line.ansi?.replace(label, "");
158+ const id = anchor ? `${anchor}:${number}` : undefined;
157159 return (
158− <div className={`flex gap-4 px-4 hover:bg-raised/40 ${line.kind === "error" ? "bg-danger/5" : ""}`}>
159− <span className="w-8 shrink-0 select-none text-right text-faint/70">{number}</span>
160+ <div
161+ id={id}
162+ className={`flex scroll-mt-24 gap-4 px-4 hover:bg-raised/40 ${targeted ? "bg-accent/15" : line.kind === "error" ? "bg-danger/5" : ""}`}
163+ >
164+ {id ? (
165+ <a href={`#${id}`} className="w-8 shrink-0 select-none text-right text-faint/70 hover:text-fg hover:underline">
166+ {number}
167+ </a>
168+ ) : (
169+ <span className="w-8 shrink-0 select-none text-right text-faint/70">{number}</span>
170+ )}
160171 {timestamps && (
161172 <time dateTime={line.at} className="w-[24ch] shrink-0 select-none text-faint/70 tabular-nums">
162173 {line.at ? shownTime(line.at) : ""}
171182 );
172183 }, sameLine);
173184
174−/** Whether a line would draw the same: a log read again as it grows makes new objects of the lines it had. */
185+/** Whether a line would draw the same: a log that grows keeps its lines, but a search finds them again. */
175186 function sameLine(a: LogLineProps, b: LogLineProps): boolean {
176187 return (
177188 a.number === b.number &&
178189 a.query === b.query &&
179190 a.timestamps === b.timestamps &&
180− a.line.kind === b.line.kind &&
181− a.line.text === b.line.text &&
182− a.line.ansi === b.line.ansi &&
183− a.line.at === b.line.at
191+ a.anchor === b.anchor &&
192+ a.targeted === b.targeted &&
193+ a.line === b.line
184194 );
185195 }
186196
197+/** A group's title: a row that folds and opens the lines under it. */
198+function GroupRow({ row, timestamps, onToggle }: { row: Extract<Row, { kind: "group" }>; timestamps: boolean; onToggle: (block: number) => void }) {
199+ return (
200+ <button
201+ type="button"
202+ aria-expanded={row.open}
203+ onClick={() => onToggle(row.block)}
204+ className="flex w-full cursor-pointer items-center gap-1.5 px-4 text-left text-fg/85 hover:bg-raised/40"
205+ >
206+ <span className="w-8 shrink-0" />
207+ {timestamps && (
208+ <time dateTime={row.at} className="ml-2.5 w-[24ch] shrink-0 select-none text-faint/70 tabular-nums">
209+ {row.at ? shownTime(row.at) : ""}
210+ </time>
211+ )}
212+ <ChevronRight size={12} className={`shrink-0 text-faint transition-transform ${row.open ? "rotate-90" : ""}`} />
213+ <span className="min-w-0 whitespace-pre-wrap break-all">{row.title}</span>
214+ </button>
215+ );
216+}
217+
187218 /**
188219 * A step's log. With `query`, only the lines holding it (any case), groups
189220 * opened, each match marked. With `timestamps`, each line's time beside it.
221+ * `anchor` names the step in each line's link (`step:3` gives `#step:3:12`),
222+ * and `target` is the line such a link names: its group opens, and it is
223+ * scrolled to and marked. A log of more than a few thousand rows draws only
224+ * those on screen (lib/log-window.ts).
190225 */
191−export function LogText({ text, query = "", timestamps = false }: { text: string; query?: string; timestamps?: boolean }) {
192− const parsed = useMemo(() => blocks(text), [text]);
193− const found = useMemo(() => searchLog(text, query), [text, query]);
194− if (!text.trim()) return <p className="px-4 py-2 text-xs text-faint">No output.</p>;
195− if (query.trim()) {
226+export function LogText({
227+ log,
228+ query = "",
229+ timestamps = false,
230+ anchor,
231+ target = null,
232+}: {
233+ log: StepLog;
234+ query?: string;
235+ timestamps?: boolean;
236+ anchor?: string;
237+ target?: number | null;
238+}) {
239+ const [open, setOpen] = useState<ReadonlySet<number>>(() => new Set());
240+ const searching = query.trim().length > 0;
241+ const found = useMemo(() => searchStepLog(log, query), [log, query]);
242+ const rows = useMemo<Row[]>(() => (searching ? found.map((entry) => ({ kind: "line", entry })) : logRows(log, open)), [searching, found, log, open]);
243+ // A link to a line in a folded group opens the group.
244+ const targetGroup = target === null ? null : groupOf(log, target);
245+ useEffect(() => {
246+ if (targetGroup !== null) setOpen((known) => (known.has(targetGroup) ? known : new Set(known).add(targetGroup)));
247+ }, [targetGroup]);
248+ const toggle = useCallback((block: number) => {
249+ setOpen((known) => {
250+ const next = new Set(known);
251+ if (!next.delete(block)) next.add(block);
252+ return next;
253+ });
254+ }, []);
255+ if (!log.hasText && !log.tail) return <p className="px-4 py-2 text-xs text-faint">No output.</p>;
256+ return <LogRows rows={rows} query={searching ? query : ""} timestamps={timestamps} anchor={anchor} target={target} onToggle={toggle} />;
257+}
258+
259+/** The rows of a step's log, every one, or past `WINDOW_FROM` those on screen and a few around them. */
260+function LogRows({
261+ rows,
262+ query,
263+ timestamps,
264+ anchor,
265+ target,
266+ onToggle,
267+}: {
268+ rows: Row[];
269+ query: string;
270+ timestamps: boolean;
271+ anchor?: string;
272+ target: number | null;
273+ onToggle: (block: number) => void;
274+}) {
275+ const windowed = rows.length > WINDOW_FROM;
276+ const box = useRef<HTMLDivElement>(null);
277+ /** Each drawn row's height, by its key, as it was last measured. */
278+ const heights = useRef(new Map<string, number>());
279+ const [measured, setMeasured] = useState(0);
280+ /** The part of the log on screen, from its top, in steps of `VIEW_STEP` pixels. */
281+ const [view, setView] = useState<[number, number]>([0, 0]);
282+ const starts = useMemo(
283+ () => (windowed ? rowStarts(rows.length, (index) => heights.current.get(rowKey(rows[index]!))) : null),
284+ // `measured` changes when a row's height does.
285+ // eslint-disable-next-line react-hooks/exhaustive-deps
286+ [windowed, rows, measured],
287+ );
288+ const [from, to] = starts ? windowOf(starts, view[0], view[1]) : [0, rows.length];
289+ // Follow the screen: the view, as the window scrolls or is resized.
290+ useEffect(() => {
291+ if (!windowed) return;
292+ let frame = 0;
293+ const look = () => {
294+ frame = 0;
295+ const rect = box.current?.getBoundingClientRect();
296+ if (!rect) return;
297+ const top = Math.floor(-rect.top / VIEW_STEP) * VIEW_STEP;
298+ const bottom = Math.ceil((window.innerHeight - rect.top) / VIEW_STEP) * VIEW_STEP;
299+ setView((known) => (known[0] === top && known[1] === bottom ? known : [top, bottom]));
300+ };
301+ const soon = () => {
302+ if (!frame) frame = requestAnimationFrame(look);
303+ };
304+ look();
305+ window.addEventListener("scroll", soon, { passive: true });
306+ window.addEventListener("resize", soon);
307+ // A step folded or opened moves the log without a scroll.
308+ document.addEventListener("toggle", soon, true);
309+ return () => {
310+ if (frame) cancelAnimationFrame(frame);
311+ window.removeEventListener("scroll", soon);
312+ window.removeEventListener("resize", soon);
313+ document.removeEventListener("toggle", soon, true);
314+ };
315+ }, [windowed]);
316+ // Measure what was drawn; a row whose height is not what was kept for it
317+ // has its space corrected.
318+ useLayoutEffect(() => {
319+ if (!windowed || !box.current) return;
320+ let changed = false;
321+ for (const element of box.current.querySelectorAll<HTMLElement>("[data-row]")) {
322+ const height = element.offsetHeight;
323+ const key = element.dataset.row!;
324+ if (height > 0 && heights.current.get(key) !== height) {
325+ heights.current.set(key, height);
326+ changed = true;
327+ }
328+ }
329+ if (changed) setMeasured((n) => n + 1);
330+ });
331+ // A link to a line: scroll to it once it is in the log.
332+ const targetRow = target === null ? -1 : rows.findIndex((row) => row.kind === "line" && row.entry.number === target);
333+ const scrolledTo = useRef<number | null>(null);
334+ useLayoutEffect(() => {
335+ if (targetRow === -1 || scrolledTo.current === target || !box.current) return;
336+ scrolledTo.current = target;
337+ const top = box.current.getBoundingClientRect().top + window.scrollY + (starts ? starts[targetRow]! : 0);
338+ if (starts) window.scrollTo({ top: Math.max(0, top - window.innerHeight / 3) });
339+ else document.getElementById(`${anchor}:${target}`)?.scrollIntoView({ block: "center" });
340+ }, [targetRow, target, starts, anchor]);
341+ const drawn = rows.slice(from, to).map((row) => {
342+ const key = rowKey(row);
196343 return (
197− <div className="py-1 font-mono text-xs leading-5">
198− {found.map(({ line, number }) => (
199− <LogLine key={number} line={line} number={number} query={query} timestamps={timestamps} />
200− ))}
344+ <div key={key} data-row={windowed ? key : undefined}>
345+ {row.kind === "line" ? (
346+ <LogLine
347+ line={row.entry.line}
348+ number={row.entry.number}
349+ query={query}
350+ timestamps={timestamps}
351+ anchor={anchor}
352+ targeted={row.entry.number === target}
353+ />
354+ ) : (
355+ <GroupRow row={row} timestamps={timestamps} onToggle={onToggle} />
356+ )}
201357 </div>
202358 );
203− }
359+ });
204360 return (
205− <div className="py-1 font-mono text-xs leading-5">
206− {parsed.map((block, index) =>
207− block.kind === "line" ? (
208− <LogLine key={index} line={block.line} number={block.number} timestamps={timestamps} />
209− ) : (
210− <details key={index} className="group/log">
211− <summary className="flex cursor-pointer list-none items-center gap-1.5 px-4 text-fg/85 hover:bg-raised/40">
212− <span className="w-8 shrink-0" />
213− {timestamps && (
214− <time dateTime={block.at} className="ml-2.5 w-[24ch] shrink-0 select-none text-faint/70 tabular-nums">
215− {block.at ? shownTime(block.at) : ""}
216− </time>
217− )}
218− <ChevronRight size={12} className="shrink-0 text-faint transition-transform group-open/log:rotate-90" />
219− {block.title}
220− </summary>
221− {block.lines.map(({ line, number }) => (
222− <LogLine key={number} line={line} number={number} timestamps={timestamps} />
223− ))}
224− </details>
225− ),
226− )}
361+ <div ref={box} className="py-1 font-mono text-xs leading-5">
362+ {starts && <div aria-hidden style={{ height: starts[from] }} />}
363+ {drawn}
364+ {starts && <div aria-hidden style={{ height: starts[rows.length]! - starts[to]! }} />}
227365 </div>
228366 );
229367 }
230368
231−/** A job's log as the page has it: each step's text (0 is the job's setup), when each last grew, and whether asking for more is failing. */
369+/** How finely the view of a windowed log is followed, in pixels: a scroll of less draws nothing new. */
370+const VIEW_STEP = 200;
371+
372+/** A job's log as the page has it: each step's log, read as it came (0 is the job's setup), when each last grew, and whether asking for more is failing. */
232373 export type JobLog = {
233− byStep: Map<number, string>;
374+ byStep: Map<number, StepLog>;
234375 /** When a chunk of each step's last arrived, by this browser's clock. */
235376 grewAt: Map<number, number>;
236377 /** The last ask failed, and the page is waiting to ask again. */
244385 * `running` is whether one of the job's steps is running now.
245386 */
246387 export function useJobLog(url: string, live: boolean, running: boolean): JobLog {
247− const [chunks, setChunks] = useState<LogChunk[]>([]);
388+ // Each chunk is read once, into its step's log, as it arrives: a long
389+ // step's log is not read again from its start for every chunk.
390+ const steps = useRef(new Map<number, StepLog>());
391+ const [byStep, setByStep] = useState<Map<number, StepLog>>(new Map());
248392 const [grewAt, setGrewAt] = useState<Map<number, number>>(new Map());
249393 const [retrying, setRetrying] = useState(false);
250394 const after = useRef(0);
252396 runningNow.current = running;
253397 useEffect(() => {
254398 after.current = 0;
255− setChunks([]);
399+ steps.current = new Map();
400+ setByStep(new Map());
256401 setGrewAt(new Map());
257402 }, [url]);
258403 useEffect(() => {
283428 if (page.chunks.length > 0) {
284429 after.current = page.chunks[page.chunks.length - 1]!.seq;
285430 const now = Date.now();
286− setChunks((known) => [...known, ...page.chunks]);
431+ for (const chunk of page.chunks) steps.current.set(chunk.step, appendLog(steps.current.get(chunk.step) ?? emptyLog(), chunk.text));
432+ setByStep(new Map(steps.current));
287433 setGrewAt((known) => {
288434 const next = new Map(known);
289435 for (const chunk of page.chunks) next.set(chunk.step, now);
315461 document.removeEventListener("visibilitychange", shown);
316462 };
317463 }, [url, live]);
318− const byStep = useMemo(() => {
319− const byStep = new Map<number, string>();
320− for (const chunk of chunks) byStep.set(chunk.step, (byStep.get(chunk.step) ?? "") + chunk.text);
321− return byStep;
322− }, [chunks]);
323464 return { byStep, grewAt, retrying };
324465 }
325466
+20−2
324324 earlier,
325325 canRerunWorkflows,
326326 settingsUrl,
327+ conflicting = false,
327328 error,
328329 loading = false,
329330 settled = false,
341342 canRerunWorkflows: boolean;
342343 /** Where the required checks are chosen, for those who may. */
343344 settingsUrl?: string | null;
345+ /**
346+ * It conflicts with its target, so its `pull_request` workflows do not
347+ * run: there is no merge commit to build, as on GitHub.
348+ */
349+ conflicting?: boolean;
344350 error?: string | null;
345351 /** The workflow runs' jobs are still being read: their rows wait, busy. */
346352 loading?: boolean;
350356 */
351357 settled?: boolean;
352358 }) {
353− if (!run && statuses.length === 0 && required.length === 0) return null;
359+ const held = conflicting && !settled;
360+ if (!run && statuses.length === 0 && required.length === 0 && !held) return null;
354361
355362 // Every workflow row, so they can be counted the same way they are shown.
356363 const workflow: Standing[] = statuses.flatMap((status) => {
374381 ? `${pending} of ${total} ${total === 1 ? "check is" : "checks are"} still running`
375382 : total > 0
376383 ? "All checks have passed"
377− : "Checks";
384+ : held
385+ ? "Workflows have not run"
386+ : "Checks";
378387 const others = [
379388 failed > 0 && `${failed} failed`,
380389 pending > 0 && `${pending} running`,
409418 <CircleX size={16} className="text-danger" />
410419 ) : standing === "running" ? (
411420 <LoaderCircle size={16} className="animate-spin text-warn" />
421+ ) : held && total === 0 ? (
422+ <CircleAlert size={16} className="text-warn" />
412423 ) : (
413424 <CircleCheck size={16} className="text-success" />
414425 )}
416427 <div className="min-w-0 grow">
417428 <p className="text-sm font-medium">{headline}</p>
418429 {sub && <p className="mt-0.5 text-xs text-muted">{sub}</p>}
430+ {held && (
431+ <p className="mt-0.5 text-xs text-muted">
432+ It conflicts with the branch it merges into, so workflows that run on{" "}
433+ <code className="font-mono">pull_request</code> do not start for it: they build the pull request
434+ merged into that branch, and there is no such merge. They run again once the conflicts are resolved.
435+ </p>
436+ )}
419437 {!summary && !settled && settingsUrl && (
420438 <p className="mt-0.5 text-xs text-muted">
421439 <Link to={settingsUrl} className="text-fg hover:underline">
+106−1
33
44 import type { JobLogText } from "@g1t/contracts";
55
6−import { archiveFiles, blocks, classify, fileName, highlight, lastStamp, searchLog, splitStamp } from "./log-lines.ts";
6+import {
7+ type StepLog,
8+ appendLog,
9+ archiveFiles,
10+ blocks,
11+ classify,
12+ emptyLog,
13+ fileName,
14+ groupOf,
15+ highlight,
16+ lastStamp,
17+ logRows,
18+ readLog,
19+ rowKey,
20+ searchLog,
21+ searchStepLog,
22+ splitStamp,
23+} from "./log-lines.ts";
24+
25+/** `text` read as chunks cut at `cuts` (offsets into it), one at a time. */
26+function readInChunks(text: string, cuts: number[]): StepLog {
27+ let log = emptyLog();
28+ let at = 0;
29+ for (const cut of [...cuts, text.length]) {
30+ log = appendLog(log, text.slice(at, cut));
31+ at = cut;
32+ }
33+ return log;
34+}
35+
36+/** What a log holds, for comparing two ways of reading it. */
37+function shape(log: StepLog) {
38+ return { blocks: log.blocks, lines: log.lines, blockOf: log.blockOf, group: log.group, lastAt: log.lastAt, hasText: log.hasText };
39+}
40+
41+test("a log read chunk by chunk is the log read at once, wherever the chunks fall", () => {
42+ const text = [
43+ "2026-10-12T09:41:07.1000000Z Job: test",
44+ "2026-10-12T09:41:07.2000000Z ##[group]Run cargo test",
45+ "2026-10-12T09:41:07.3000000Z cargo test --locked",
46+ "2026-10-12T09:41:07.4000000Z \u001b[32mok\u001b[0m",
47+ "2026-10-12T09:41:08.0000000Z ##[endgroup]",
48+ "2026-10-12T09:41:09.0000000Z ##[error]Process completed with exit code 101.",
49+ "",
50+ ].join("\n");
51+ const whole = readLog(text);
52+ // Every cut, and many cuts, including inside a line and inside an escape code.
53+ for (let cut = 0; cut <= text.length; cut++) assert.deepEqual(shape(readInChunks(text, [cut])), shape(whole), `cut at ${cut}`);
54+ assert.deepEqual(shape(readInChunks(text, Array.from({ length: text.length }, (_, i) => i))), shape(whole));
55+ assert.equal(whole.lastAt, "2026-10-12T09:41:09.0000000Z");
56+ assert.deepEqual(whole.blocks, blocks(text));
57+});
58+
59+test("a group a chunk leaves open holds the next chunk's lines", () => {
60+ let log = appendLog(emptyLog(), "before\n##[group]Install\none\n");
61+ assert.equal(log.group, 1);
62+ log = appendLog(log, "two\n##[endgroup]\nafter\n");
63+ assert.deepEqual(
64+ log.blocks.map((block) => (block.kind === "line" ? block.line.text : `${block.title}: ${block.lines.map((entry) => entry.line.text).join(",")}`)),
65+ ["before", "Install: one,two", "after"],
66+ );
67+ assert.equal(log.group, null);
68+ assert.equal(groupOf(log, 3), 1);
69+ assert.equal(groupOf(log, 4), null);
70+});
71+
72+test("a line still being written shows, and is read once it ends", () => {
73+ let log = appendLog(emptyLog(), "one\ntw");
74+ assert.equal(log.lines.length, 1);
75+ assert.deepEqual(log.tail, { line: { kind: "text", text: "tw" }, number: 2 });
76+ assert.deepEqual(searchStepLog(log, "TW").map((found) => found.number), [2]);
77+ log = appendLog(log, "o\n");
78+ assert.equal(log.tail, undefined);
79+ assert.deepEqual(log.lines.map((entry) => entry.line.text), ["one", "two"]);
80+ // A group's marker cut in two opens the group once it is whole.
81+ log = appendLog(log, "##[gro");
82+ assert.equal(log.tail, undefined, "half a marker is not a line");
83+ log = appendLog(log, "up]Later\nthree\n");
84+ assert.equal(groupOf(log, 3), 2);
85+ assert.equal(readLog(" \n\n").hasText, false);
86+});
87+
88+test("reading a long log chunk by chunk reads each line once", () => {
89+ const line = (n: number) => `2026-10-12T09:41:07.0000000Z ${n % 500 === 0 ? "##[group]Batch" : n % 500 === 20 ? "##[endgroup]" : `test case_${n} ... ok`}\n`;
90+ let log = emptyLog();
91+ const started = performance.now();
92+ for (let chunk = 0; chunk < 500; chunk++) {
93+ let text = "";
94+ for (let n = chunk * 100; n < chunk * 100 + 100; n++) text += line(n);
95+ log = appendLog(log, text);
96+ }
97+ const took = performance.now() - started;
98+ assert.equal(log.lines.length, 50_000 - 200);
99+ // Reading the whole text again for every chunk took about 25 s here.
100+ assert.ok(took < 2000, `50,000 lines in 500 chunks took ${Math.round(took)} ms`);
101+});
102+
103+test("a log's rows are its lines and groups' titles, open groups' lines under them", () => {
104+ const log = readLog("a\n##[group]G\nb\nc\n##[endgroup]\nd\n");
105+ const keys = (open: number[]) => logRows(log, new Set(open)).map(rowKey);
106+ assert.deepEqual(keys([]), ["L1", "G1", "L4"]);
107+ assert.deepEqual(keys([1]), ["L1", "G1", "L2", "L3", "L4"]);
108+ assert.deepEqual(logRows(log, new Set())[1], { kind: "group", block: 1, title: "G", at: undefined, open: false, size: 2 });
109+ // A search reads a log once a change.
110+ assert.equal(searchStepLog(log, "b"), searchStepLog(log, " B "));
111+});
7112
8113 const LOG = ["Job: test", "##[group]Run cargo test", "cargo test --locked", "##[endgroup]", "test greet ... FAILED", "##[error]Process completed with exit code 101."].join("\n");
9114
+139−29
5858 return lineOf("text", raw, at);
5959 }
6060
61+/** A line and its number in the step's log. */
62+export type Numbered = { line: Line; number: number };
63+
64+/**
65+ * A step's log, read as it arrives (`appendLog`): each chunk's lines are
66+ * read once, however long the log grows, and a group a chunk leaves open
67+ * holds the next chunk's lines until its `##[endgroup]`.
68+ */
69+export type StepLog = {
70+ /** Plain lines and groups, as `blocks` reads them. */
71+ blocks: Block[];
72+ /** Every line in order, groups' lines among them: `lines[n - 1]` is line `n`. */
73+ lines: Numbered[];
74+ /** The index in `blocks` of the block that holds each line, by the same index. */
75+ blockOf: number[];
76+ /** The group still open at the end of what has arrived, by its index in `blocks`. */
77+ group: number | null;
78+ /** What came after the last newline: a line still being written, shown as `tail` meanwhile. */
79+ partial: string;
80+ tail?: Numbered;
81+ /** The time of the last stamped line. */
82+ lastAt?: string;
83+ /** Whether any line holds more than spaces. */
84+ hasText: boolean;
85+};
86+
87+export function emptyLog(): StepLog {
88+ return { blocks: [], lines: [], blockOf: [], group: null, partial: "", hasText: false };
89+}
90+
91+/**
92+ * `log` with `text` added to its end. Only the new lines are read: the
93+ * answer shares `log`'s arrays, adding to them, so `log` is not to be read
94+ * or added to again.
95+ */
96+export function appendLog(log: StepLog, text: string): StepLog {
97+ const next: StepLog = { ...log };
98+ delete next.tail;
99+ const joined = next.partial + text;
100+ const end = joined.lastIndexOf("\n");
101+ const whole = end === -1 ? "" : joined.slice(0, end);
102+ next.partial = end === -1 ? joined : joined.slice(end + 1);
103+ if (end !== -1) {
104+ for (const raw of whole.split("\n")) readLine(next, raw);
105+ }
106+ // A line that may yet be a group's marker (`##[gro`) waits to end.
107+ if (next.partial.trim() && !splitStamp(next.partial).rest.startsWith("##")) {
108+ const line = classify(next.partial);
109+ if (typeof line === "object" && !("group" in line)) next.tail = { line, number: next.lines.length + 1 };
110+ }
111+ return next;
112+}
113+
114+/** Reads one whole line into `log`, in place. */
115+function readLine(log: StepLog, raw: string): void {
116+ // A log ends with a newline; nothing follows the last one.
117+ if (raw === "" && log.lines.length === 0) return;
118+ const line = classify(raw);
119+ if (line === "group-end") {
120+ log.group = null;
121+ return;
122+ }
123+ if ("group" in line) {
124+ log.group = log.blocks.length;
125+ log.blocks.push(line.at ? { kind: "group", title: line.group, lines: [], at: line.at } : { kind: "group", title: line.group, lines: [] });
126+ return;
127+ }
128+ const entry = { line, number: log.lines.length + 1 };
129+ log.lines.push(entry);
130+ if (line.at) log.lastAt = line.at;
131+ if (!log.hasText && line.text.trim()) log.hasText = true;
132+ const open = log.group === null ? undefined : log.blocks[log.group];
133+ if (open?.kind === "group") {
134+ open.lines.push(entry);
135+ log.blockOf.push(log.group!);
136+ } else {
137+ log.blockOf.push(log.blocks.length);
138+ log.blocks.push({ kind: "line", ...entry });
139+ }
140+}
141+
142+/** A whole log, read at once. */
143+export function readLog(text: string): StepLog {
144+ return appendLog(emptyLog(), text.endsWith("\n") || text === "" ? text : `${text}\n`);
145+}
146+
61147 /** Lines into blocks: plain lines, and groups that fold. Lines are numbered from 1, group markers aside. */
62148 export function blocks(text: string): Block[] {
63− const out: Block[] = [];
64− let group: Extract<Block, { kind: "group" }> | null = null;
65− let number = 0;
66− // A log ends with a newline; nothing follows the last one.
67− for (const raw of (text.endsWith("\n") ? text.slice(0, -1) : text).split("\n")) {
68− if (raw === "" && number === 0) continue;
69− const line = classify(raw);
70− if (line === "group-end") {
71− group = null;
72− continue;
73− }
74− if ("group" in line) {
75− group = line.at ? { kind: "group", title: line.group, lines: [], at: line.at } : { kind: "group", title: line.group, lines: [] };
76− out.push(group);
77− continue;
78− }
79− number += 1;
80− if (group) group.lines.push({ line, number });
81− else out.push({ kind: "line", line, number });
82− }
83− return out;
149+ return readLog(text).blocks;
84150 }
85151
86−/** The lines of a log holding `query` (any case), numbered as `blocks` numbers them, folded groups opened. */
87−export function searchLog(text: string, query: string): { line: Line; number: number }[] {
152+/** The last search of each log, so a step's count and its lines are found once a change. */
153+const searched = new WeakMap<StepLog, { query: string; found: Numbered[] }>();
154+
155+/** The lines of a step's log holding `query` (any case), in order, folded groups' lines among them. */
156+export function searchStepLog(log: StepLog, query: string): Numbered[] {
88157 const wanted = query.trim().toLowerCase();
89158 if (!wanted) return [];
90− const found: { line: Line; number: number }[] = [];
91− for (const block of blocks(text)) {
92− const lines = block.kind === "line" ? [{ line: block.line, number: block.number }] : block.lines;
93− for (const entry of lines) {
94− if (entry.line.text.toLowerCase().includes(wanted)) found.push(entry);
95− }
159+ const known = searched.get(log);
160+ if (known && known.query === wanted) return known.found;
161+ const found: Numbered[] = [];
162+ for (const entry of log.lines) {
163+ if (entry.line.text.toLowerCase().includes(wanted)) found.push(entry);
96164 }
165+ if (log.tail && log.tail.line.text.toLowerCase().includes(wanted)) found.push(log.tail);
166+ searched.set(log, { query: wanted, found });
97167 return found;
98168 }
99169
170+/** The lines of a log holding `query` (any case), numbered as `blocks` numbers them, folded groups opened. */
171+export function searchLog(text: string, query: string): Numbered[] {
172+ return searchStepLog(readLog(text), query);
173+}
174+
175+/**
176+ * What a step's log draws, one row each, top to bottom: its lines, and
177+ * each group's title, with the lines of the groups in `open` (by their
178+ * index in `blocks`) under it.
179+ */
180+export type Row =
181+ | { kind: "line"; entry: Numbered }
182+ | { kind: "group"; block: number; title: string; at?: string; open: boolean; size: number };
183+
184+export function logRows(log: StepLog, open: ReadonlySet<number>): Row[] {
185+ const rows: Row[] = [];
186+ log.blocks.forEach((block, index) => {
187+ if (block.kind === "line") {
188+ rows.push({ kind: "line", entry: block });
189+ return;
190+ }
191+ const isOpen = open.has(index);
192+ rows.push({ kind: "group", block: index, title: block.title, at: block.at, open: isOpen, size: block.lines.length });
193+ if (isOpen) for (const entry of block.lines) rows.push({ kind: "line", entry });
194+ });
195+ if (log.tail) rows.push({ kind: "line", entry: log.tail });
196+ return rows;
197+}
198+
199+/** The group that holds line `number`, by its index in `blocks`, or null when it is no group's. */
200+export function groupOf(log: StepLog, number: number): number | null {
201+ const index = log.blockOf[number - 1];
202+ return index !== undefined && log.blocks[index]?.kind === "group" ? index : null;
203+}
204+
205+/** A row's key, the same however the rows above it change. */
206+export function rowKey(row: Row): string {
207+ return row.kind === "line" ? `L${row.entry.number}` : `G${row.block}`;
208+}
209+
100210 /** `text` cut where `query` (any case) appears, each piece marked as a match or not. */
101211 export function highlight(text: string, query: string): { text: string; match: boolean }[] {
102212 const wanted = query.trim().toLowerCase();
+28−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { ROW_HEIGHT, rowAt, rowStarts, windowOf } from "./log-window.ts";
5+
6+test("rows start where the rows above them end, measured or not", () => {
7+ const starts = rowStarts(4, (index) => (index === 1 ? 60 : undefined));
8+ assert.deepEqual([...starts], [0, ROW_HEIGHT, ROW_HEIGHT + 60, 2 * ROW_HEIGHT + 60, 3 * ROW_HEIGHT + 60]);
9+ assert.equal(rowAt(starts, 0), 0);
10+ assert.equal(rowAt(starts, ROW_HEIGHT), 1);
11+ assert.equal(rowAt(starts, ROW_HEIGHT + 59), 1);
12+ assert.equal(rowAt(starts, ROW_HEIGHT + 60), 2);
13+ assert.equal(rowAt(starts, 10_000), 3, "past the end is the last row");
14+});
15+
16+test("only the rows on screen and a little around them are drawn", () => {
17+ const starts = rowStarts(50_000, () => undefined);
18+ // The screen shows rows 1000 to 1040.
19+ const [from, to] = windowOf(starts, 1000 * ROW_HEIGHT, 1040 * ROW_HEIGHT, 200);
20+ assert.deepEqual([from, to], [990, 1051]);
21+ // At the very end, following the newest line.
22+ const end = starts[50_000]!;
23+ assert.deepEqual(windowOf(starts, end - 800, end, 200), [49_950, 50_000]);
24+ // The log above or below the screen draws a few rows at the near end.
25+ assert.deepEqual(windowOf(starts, -5000, -4000, 200), [0, 20]);
26+ assert.deepEqual(windowOf(starts, end + 4000, end + 5000, 200), [49_980, 50_000]);
27+ assert.deepEqual(windowOf(rowStarts(0, () => undefined), 0, 100), [0, 0]);
28+});
+58−0
1+/**
2+ * Drawing a long log a screenful at a time. A step's log past a few
3+ * thousand rows draws only the rows on screen and a few around them, with
4+ * space above and below standing in for the rest, so a 50,000-line log
5+ * opens, grows and scrolls as fast as a short one. Rows are one line high
6+ * until they are drawn and measured (a long line wraps), and the space
7+ * kept for them is corrected then.
8+ */
9+
10+/** Past this many rows, a step's log draws only what is on screen. */
11+export const WINDOW_FROM = 2000;
12+/** A row's height before it is measured: one line of `text-xs leading-5`. */
13+export const ROW_HEIGHT = 20;
14+/** How far past the screen rows are drawn, above and below, in pixels. */
15+export const OVERSCAN = 800;
16+
17+/**
18+ * Where each row starts, from the top of the log: `starts[i]` for row `i`,
19+ * and `starts[rows]` for the log's whole height. `height(i)` is a row's
20+ * measured height, or undefined for the estimate.
21+ */
22+export function rowStarts(rows: number, height: (index: number) => number | undefined): Float64Array {
23+ const starts = new Float64Array(rows + 1);
24+ for (let i = 0; i < rows; i++) starts[i + 1] = starts[i]! + (height(i) ?? ROW_HEIGHT);
25+ return starts;
26+}
27+
28+/** The first row that ends below `y`: the row at `y`, or the last. */
29+export function rowAt(starts: Float64Array, y: number): number {
30+ const rows = starts.length - 1;
31+ let low = 0;
32+ let high = rows;
33+ while (low < high) {
34+ const middle = (low + high) >> 1;
35+ if (starts[middle + 1]! <= y) low = middle + 1;
36+ else high = middle;
37+ }
38+ return Math.min(low, Math.max(0, rows - 1));
39+}
40+
41+/**
42+ * The rows to draw, `[from, to)`, for a view from `top` to `bottom`
43+ * (pixels from the top of the log; either may be past its ends), with
44+ * `overscan` pixels more either side.
45+ */
46+export function windowOf(starts: Float64Array, top: number, bottom: number, overscan = OVERSCAN): [number, number] {
47+ const rows = starts.length - 1;
48+ if (rows === 0) return [0, 0];
49+ const total = starts[rows]!;
50+ const from = top - overscan;
51+ const to = bottom + overscan;
52+ if (to <= 0 || from >= total) {
53+ // Off screen: draw a few rows at the end nearest the view, so the
54+ // log has something to measure and its end can be followed.
55+ return from >= total ? [Math.max(0, rows - 20), rows] : [0, Math.min(rows, 20)];
56+ }
57+ return [rowAt(starts, Math.max(0, from)), rowAt(starts, to) + 1];
58+}
+44−13
4949 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
5050 import { Hint } from "../../components/ui/hint";
5151 import { useWorkflowReason } from "../../components/mirror";
52−import { lastStamp, searchLog } from "../../lib/log-lines";
52+import { type StepLog, searchStepLog } from "../../lib/log-lines";
5353 import { quietWords } from "../../lib/log-poll";
5454 import { listArtifacts, withLegacyArtifacts } from "../../lib/run-artifacts.server";
5555 import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "../../lib/run-artifacts";
140140
141141 function StepRow({
142142 step,
143− text,
143+ log,
144144 defaultOpen,
145145 query = "",
146146 matches = 0,
148148 lastOutput = null,
149149 now,
150150 end,
151+ target = null,
151152 }: {
152153 step: StepState;
153− text: string | undefined;
154+ log: StepLog | undefined;
154155 defaultOpen: boolean;
155156 /** A search of the job's log: only its matching lines are shown. */
156157 query?: string;
162163 now: number;
163164 /** Marks the end of the running step's log, for following it. */
164165 end?: Ref<HTMLDivElement>;
166+ /** The line of this step a link names (`#step:3:12`). */
167+ target?: number | null;
165168 }) {
166169 const running = step.status === "in_progress";
167170 const quiet = running && !query ? quietWords(step.startedAt ? new Date(step.startedAt).getTime() : null, lastOutput, now) : null;
181184 </span>
182185 </summary>
183186 <div className="border-t border-line bg-bg/60">
184− {text === undefined && !running ? (
187+ {log === undefined && !running ? (
185188 <p className="px-4 py-2 text-xs text-faint">{step.status === "queued" ? "Not started." : step.conclusion === "skipped" ? "Skipped." : "No output."}</p>
186189 ) : (
187− text !== undefined && <LogText text={text} query={query} timestamps={timestamps} />
190+ log !== undefined && <LogText log={log} query={query} timestamps={timestamps} anchor={`step:${step.number}`} target={target} />
188191 )}
189192 {quiet && (
190193 <p className="flex items-center gap-2 px-4 py-2 font-mono text-xs text-muted" aria-live="polite" suppressHydrationWarning>
198201 );
199202 }
200203
204+/**
205+ * The line of a job's log the page's address names, as GitHub's do:
206+ * `#step:3:12` is line 12 of step 3 (0 is Set up job). A line's number
207+ * links to it.
208+ */
209+function useLineTarget(): { step: number; line: number } | null {
210+ const [target, setTarget] = useState<{ step: number; line: number } | null>(null);
211+ useEffect(() => {
212+ const read = () => {
213+ const found = /^#step:(\d+):(\d+)$/.exec(window.location.hash);
214+ setTarget((known) => {
215+ if (!found) return null;
216+ const next = { step: Number(found[1]), line: Number(found[2]) };
217+ return known && known.step === next.step && known.line === next.line ? known : next;
218+ });
219+ };
220+ read();
221+ window.addEventListener("hashchange", read);
222+ return () => window.removeEventListener("hashchange", read);
223+ }, []);
224+ return target;
225+}
226+
201227 /** Whether the viewer wants each log line's time shown, kept in this browser. */
202228 function useTimestamps(): [boolean, (on: boolean) => void] {
203229 const [on, setOn] = useState(false);
478504 const [query, setQuery] = useState("");
479505 const [timestamps, setTimestamps] = useTimestamps();
480506 const now = useNow(live);
481− const runningText = running ? log.get(running.number) : undefined;
507+ const runningLog = running ? log.get(running.number) : undefined;
508+ const target = useLineTarget();
482509 // The running step's log is followed while its end is in view, unless a
483510 // failed step is the one held open.
484511 const followed = !failed && running ? running.number : null;
485− const { end, behind, jump } = useFollow(followed, runningText);
512+ const { end, behind, jump } = useFollow(followed, runningLog);
486513 // When the running step last printed: its last line's time, else when
487514 // its last chunk came.
488515 const lastOutput = useMemo(() => {
489516 if (!running) return null;
490− const at = runningText ? lastStamp(runningText) : undefined;
517+ const at = runningLog?.lastAt;
491518 if (at) return new Date(at).getTime();
492− return runningText ? (grewAt.get(running.number) ?? null) : null;
493− }, [running, runningText, grewAt]);
519+ return runningLog ? (grewAt.get(running.number) ?? null) : null;
520+ }, [running, runningLog, grewAt]);
494521 const searching = query.trim().length > 0;
495522 const setUp: StepState = {
496523 number: 0,
502529 };
503530 // While searching, the steps with a match, each with how many.
504531 const steps = [setUp, ...job.steps]
505− .map((step) => ({ step, matches: searching ? searchLog(log.get(step.number) ?? "", query).length : 0 }))
532+ .map((step) => {
533+ const stepLog = log.get(step.number);
534+ return { step, matches: searching && stepLog ? searchStepLog(stepLog, query).length : 0 };
535+ })
506536 .filter(({ matches }) => !searching || matches > 0);
507537 const total = steps.reduce((sum, { matches }) => sum + matches, 0);
508538 return (
605635 // Searching opens every step with a match; clearing it puts them back.
606636 key={`${step.number}:${searching}`}
607637 step={step}
608− text={log.get(step.number)}
609− defaultOpen={searching || (step.number !== 0 && step.number === (failed ?? running)?.number)}
638+ log={log.get(step.number)}
639+ defaultOpen={searching || step.number === target?.step || (step.number !== 0 && step.number === (failed ?? running)?.number)}
610640 query={searching ? query : ""}
611641 matches={matches}
612642 timestamps={timestamps}
613643 now={now}
614644 lastOutput={step.number === running?.number ? lastOutput : null}
615645 end={step.number === followed ? end : undefined}
646+ target={step.number === target?.step ? target.line : null}
616647 />
617648 ))}
618649 </Card>
+1−0
677677 earlier={earlierChecks}
678678 canRerunWorkflows={canMerge}
679679 settingsUrl={canProtect ? `${base}/settings/branches` : null}
680+ conflicting={mergeable === "conflicting" && !noChecks}
680681 error={actionData?.action === "rerun-failed" || actionData?.action === "rerun-workflow" ? actionData.error : null}
681682 loading={loading}
682683 />
+113−0
1+// What a job looks like from inside, by shape, for comparing a run on
2+// GitHub with the same run on g1t (scripts/conformance-diff.mjs). Values
3+// that differ between hosts by nature (ids, times, hosts, paths) are kept
4+// as their shape: which variables are set, which keys the event and the
5+// contexts have and of what JSON type, and how paths sit relative to one
6+// another. Run by .g1t/workflows/conformance.yml and its copy in
7+// .github/workflows/; see CONTRIBUTING.md.
8+//
9+// node conformance/report.mjs <out.json>
10+// with CTX_<NAME> set to toJSON of each context to compare by shape
11+// (github, runner, job, strategy, matrix, steps, needs), and CHECK_<name>
12+// to each value the steps and expressions should give alike everywhere.
13+
14+import { readFileSync, writeFileSync } from "node:fs";
15+import { relative } from "node:path";
16+
17+/** A JSON value's shape: every key path with its type, arrays by their first item. */
18+export function shape(value, prefix = "", into = {}) {
19+ const type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
20+ if (prefix) into[prefix] = type;
21+ if (type === "object") {
22+ for (const [key, inner] of Object.entries(value)) shape(inner, prefix ? `${prefix}.${key}` : key, into);
23+ } else if (type === "array" && value.length > 0) {
24+ shape(value[0], `${prefix}[]`, into);
25+ }
26+ return into;
27+}
28+
29+/** What kind of text a variable holds, without its value. */
30+export function kind(text) {
31+ if (text === "") return "empty";
32+ if (/^(true|false)$/.test(text)) return "boolean";
33+ if (/^\d+$/.test(text)) return "integer";
34+ if (/^[0-9a-f]{40}$/.test(text)) return "sha";
35+ if (/^https?:\/\//.test(text)) return "url";
36+ if (/^(\/|[A-Za-z]:\\)/.test(text)) return "path";
37+ if (/^refs\//.test(text)) return "ref";
38+ return "text";
39+}
40+
41+/** The variables GitHub's runner sets, by kind; others are left out. */
42+export function variables(env) {
43+ const out = {};
44+ for (const [name, value] of Object.entries(env)) {
45+ if (/^(GITHUB_|RUNNER_|ACTIONS_)/.test(name) && !name.startsWith("CTX_") || ["CI", "ImageOS", "ImageVersion", "HOME"].includes(name)) {
46+ out[name] = kind(value ?? "");
47+ }
48+ }
49+ return out;
50+}
51+
52+/** How the job's folders sit relative to one another. */
53+export function layout(env) {
54+ const at = (name) => env[name] ?? "";
55+ return {
56+ workspace_under_runner_workspace: at("GITHUB_WORKSPACE") !== "" && !relative(at("RUNNER_WORKSPACE"), at("GITHUB_WORKSPACE")).startsWith(".."),
57+ workspace_is_repo_twice: (() => {
58+ const parts = at("GITHUB_WORKSPACE").split(/[\\/]/).filter(Boolean);
59+ return parts.length >= 2 && parts[parts.length - 1] === parts[parts.length - 2];
60+ })(),
61+ temp_under_runner: at("RUNNER_TEMP") !== "" && at("RUNNER_TEMP").includes("runner"),
62+ event_file_exists: (() => {
63+ try {
64+ readFileSync(at("GITHUB_EVENT_PATH"));
65+ return true;
66+ } catch {
67+ return false;
68+ }
69+ })(),
70+ };
71+}
72+
73+/** The variables starting with `prefix`, by the rest of their names, lowercased. */
74+function prefixed(env, prefix) {
75+ return Object.fromEntries(
76+ Object.entries(env)
77+ .filter(([name]) => name.startsWith(prefix))
78+ .map(([name, value]) => [name.slice(prefix.length).toLowerCase(), value ?? ""]),
79+ );
80+}
81+
82+export function report(env) {
83+ const contexts = Object.fromEntries(
84+ Object.entries(prefixed(env, "CTX_")).map(([name, text]) => {
85+ try {
86+ return [name, JSON.parse(text)];
87+ } catch {
88+ return [name, text];
89+ }
90+ }),
91+ );
92+ const event = (() => {
93+ try {
94+ return JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf8"));
95+ } catch {
96+ return null;
97+ }
98+ })();
99+ return {
100+ variables: variables(env),
101+ layout: layout(env),
102+ contexts: Object.fromEntries(Object.entries(contexts).map(([name, value]) => [name, shape(value)])),
103+ event: event ? shape(event) : null,
104+ // What earlier steps saw, compared by value: these should be the same everywhere.
105+ checks: prefixed(env, "CHECK_"),
106+ };
107+}
108+
109+if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
110+ const out = process.argv[2] ?? "conformance.json";
111+ writeFileSync(out, `${JSON.stringify(report(process.env), null, 2)}\n`);
112+ console.log(`Wrote ${out}`);
113+}
+42−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { kind, layout, shape, variables } from "./report.mjs";
5+
6+test("a value's shape is its key paths and types, not its values", () => {
7+ assert.deepEqual(shape({ a: 1, b: { c: "x", d: [ { e: true } ] }, f: null }), {
8+ a: "number",
9+ b: "object",
10+ "b.c": "string",
11+ "b.d": "array",
12+ "b.d[]": "object",
13+ "b.d[].e": "boolean",
14+ f: "null",
15+ });
16+});
17+
18+test("variables are kept by kind, and only GitHub's", () => {
19+ assert.deepEqual(
20+ variables({ GITHUB_SHA: "a".repeat(40), GITHUB_RUN_ID: "42", GITHUB_SERVER_URL: "https://g1t.sh", RUNNER_TEMP: "/home/runner/work/_temp", CI: "true", PATH: "/bin", SECRET: "x" }),
21+ { GITHUB_SHA: "sha", GITHUB_RUN_ID: "integer", GITHUB_SERVER_URL: "url", RUNNER_TEMP: "path", CI: "boolean" },
22+ );
23+ assert.equal(kind("refs/heads/main"), "ref");
24+ assert.equal(kind(""), "empty");
25+});
26+
27+test("the layout says where the workspace is, as GitHub lays it out", () => {
28+ const on = layout({ RUNNER_WORKSPACE: "/home/runner/work/web", GITHUB_WORKSPACE: "/home/runner/work/web/web", RUNNER_TEMP: "/home/runner/work/_temp" });
29+ assert.equal(on.workspace_under_runner_workspace, true);
30+ assert.equal(on.workspace_is_repo_twice, true);
31+ assert.equal(layout({ GITHUB_WORKSPACE: "/home/runner/work/repo" }).workspace_is_repo_twice, false);
32+});
33+
34+test("the diff names what g1t lacks or has differently, and what it adds", async () => {
35+ const { compare } = await import("../scripts/conformance-diff.mjs");
36+ const github = { variables: { GITHUB_SHA: "sha", ImageOS: "text" }, layout: { a: true }, contexts: { github: { sha: "string", "event.ref": "string" } }, event: { ref: "string" }, checks: { format: "a-1-{2}" } };
37+ const g1t = { variables: { GITHUB_SHA: "sha", G1T: "boolean" }, layout: { a: false }, contexts: { github: { sha: "string", "event.ref": "number" } }, event: { ref: "string" }, checks: { format: "a-1-{2}" } };
38+ const found = compare(github, g1t);
39+ assert.deepEqual(found.missing, ["variable ImageOS (text)"]);
40+ assert.deepEqual(found.different, ["layout a: GitHub true, g1t false", 'github. event.ref: GitHub "string", g1t "number"']);
41+ assert.deepEqual(found.extra, ["variable G1T (boolean)"]);
42+});
+86−7
104104 self.repository.split('/').next().unwrap_or_default()
105105 }
106106
107− /// The owner's id, as the event's `repository.owner.id` gives it.
107+ /// The owner's id, as the event's `repository.owner.id` gives it, or
108+ /// else the number for the owner's name ([`account_id`]).
108109 fn owner_id(&self) -> String {
109− owner_id(&self.event).unwrap_or_default()
110+ owner_id(&self.event).unwrap_or_else(|| match self.owner() {
111+ "" => String::new(),
112+ owner => account_id(owner).to_string(),
113+ })
114+ }
115+
116+ /// `github.repository_id`: the repository's number ([`github_id`]).
117+ fn repository_number(&self) -> String {
118+ id_text(&self.repository_id)
119+ }
120+
121+ /// `github.actor_id`: the actor's number, as the event's `sender.id`
122+ /// gives it ([`account_id`]).
123+ fn actor_number(&self) -> String {
124+ match self.actor.as_str() {
125+ "" => id_text(&self.actor_id),
126+ actor => account_id(actor).to_string(),
127+ }
110128 }
111129
112130 /// The `github` context for a job. `token` is `github.token` (and
118136 "action_ref": "",
119137 "action_repository": "",
120138 "actor": self.actor,
121− "actor_id": self.actor_id,
139+ "actor_id": self.actor_number(),
122140 "api_url": self.api_url,
123141 "base_ref": self.base_ref.clone().unwrap_or_default(),
124142 "env": "",
134152 "ref_protected": self.ref_name() == self.default_branch,
135153 "ref_type": self.ref_type(),
136154 "repository": self.repository,
137− "repository_id": self.repository_id,
155+ "repository_id": self.repository_number(),
138156 "repository_owner": self.owner(),
139157 "repository_owner_id": self.owner_id(),
140158 "repositoryUrl": format!("{}/{}.git", self.server_url, self.repository),
164182 set("GITHUB_ACTIONS", "true".into());
165183 set("G1T", "true".into());
166184 set("GITHUB_ACTOR", self.actor.clone());
167− set("GITHUB_ACTOR_ID", self.actor_id.clone());
185+ set("GITHUB_ACTOR_ID", self.actor_number());
168186 set("GITHUB_API_URL", self.api_url.clone());
169187 set("GITHUB_BASE_REF", self.base_ref.clone().unwrap_or_default());
170188 set("GITHUB_EVENT_NAME", self.event_name.clone());
177195 set("GITHUB_REF_PROTECTED", (self.ref_name() == self.default_branch).to_string());
178196 set("GITHUB_REF_TYPE", self.ref_type().into());
179197 set("GITHUB_REPOSITORY", self.repository.clone());
180− set("GITHUB_REPOSITORY_ID", self.repository_id.clone());
198+ set("GITHUB_REPOSITORY_ID", self.repository_number());
181199 set("GITHUB_REPOSITORY_OWNER", self.owner().to_owned());
182200 set("GITHUB_REPOSITORY_OWNER_ID", self.owner_id());
183201 set("GITHUB_RETENTION_DAYS", "14".into());
202220 }
203221 }
204222
223+/// The whole number GitHub gives an object as its `id`, for a g1t id
224+/// (`rep_01kpw0…`, `pr_01…`): the same number every time, positive, and
225+/// below 2^53 so JavaScript reads it exactly. g1t's own id stays the
226+/// object's `node_id`.
227+pub fn github_id(id: &str) -> u64 {
228+ // FNV-1a, 64 bits, cut to 53.
229+ let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
230+ for byte in id.bytes() {
231+ hash ^= u64::from(byte);
232+ hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
233+ }
234+ (hash & ((1 << 53) - 1)).max(1)
235+}
236+
237+/// The number of a person or workspace, by their name: usernames and
238+/// workspaces share one namespace, so `sender.id`, `user.id`,
239+/// `repository.owner.id`, `github.actor_id` and
240+/// `github.repository_owner_id` agree wherever the same name appears, and
241+/// a personal workspace's repositories are owned by its person's number,
242+/// as on GitHub. Case does not matter.
243+pub fn account_id(login: &str) -> u64 {
244+ github_id(&format!("account:{}", login.to_ascii_lowercase()))
245+}
246+
247+/// An id as text of a number: one that is already a number as it is,
248+/// a g1t id as its [`github_id`], nothing as nothing.
249+fn id_text(id: &str) -> String {
250+ if id.is_empty() || id.bytes().all(|b| b.is_ascii_digit()) {
251+ id.to_owned()
252+ } else {
253+ github_id(id).to_string()
254+ }
255+}
256+
205257 /// The owner's id in a webhook-shaped payload, `repository.owner.id`, a
206258 /// number or a string.
207259 pub fn owner_id(event: &Value) -> Option<String> {
301353 assert_eq!(vars["GITHUB_JOB"], "build");
302354 assert_eq!(vars["RUNNER_OS"], "Linux");
303355 assert_eq!(vars["RUNNER_WORKSPACE"], "/home/runner/work");
304− assert_eq!(vars["GITHUB_REPOSITORY_OWNER_ID"], "");
356+ assert_eq!(vars["GITHUB_REPOSITORY_OWNER_ID"], account_id("acme").to_string());
357+ }
358+
359+ #[test]
360+ fn ids_are_whole_numbers_as_on_github() {
361+ let id = github_id("rep_01kpw0abcdefghjkmnpqrstvwx");
362+ assert_eq!(id, github_id("rep_01kpw0abcdefghjkmnpqrstvwx"), "the same every time");
363+ assert!(id > 0 && id < 1 << 53, "a safe integer in JavaScript");
364+ assert_ne!(id, github_id("rep_01kpw0abcdefghjkmnpqrstvwy"));
365+ assert_eq!(account_id("Ada"), account_id("ada"));
366+ assert_ne!(account_id("ada"), github_id("ada"));
367+ let info = RunInfo {
368+ repository: "acme/web".into(),
369+ repository_id: "rep_1".into(),
370+ actor: "ada".into(),
371+ actor_id: "usr_1".into(),
372+ ..RunInfo::default()
373+ };
374+ let github = info.context("build", "", None);
375+ assert_eq!(github["repository_id"], github_id("rep_1").to_string());
376+ assert_eq!(github["actor_id"], account_id("ada").to_string());
377+ assert_eq!(github["repository_owner_id"], account_id("acme").to_string());
378+ let vars = info.variables("build");
379+ assert_eq!(vars["GITHUB_REPOSITORY_ID"], github["repository_id"]);
380+ assert_eq!(vars["GITHUB_ACTOR_ID"], github["actor_id"]);
381+ assert!(vars["GITHUB_REPOSITORY_ID"].as_str().unwrap().bytes().all(|b| b.is_ascii_digit()));
382+ // A number already is one.
383+ assert_eq!(RunInfo { repository_id: "42".into(), ..RunInfo::default() }.context("", "", None)["repository_id"], "42");
305384 }
306385
307386 #[test]
+12−0
337337 let job = base.jobs.iter().find(|j| j.id == "build").unwrap();
338338 assert_eq!(job.runs_on, json!(["self-hosted", "docker"]));
339339 }
340+
341+#[test]
342+fn the_conformance_workflow_is_the_same_on_github_and_on_g1t() {
343+ let root = workflows_dir().parent().unwrap().parent().unwrap().to_path_buf();
344+ let ours = std::fs::read_to_string(root.join(".g1t/workflows/conformance.yml")).unwrap();
345+ let theirs = std::fs::read_to_string(root.join(".github/workflows/conformance.yml")).unwrap();
346+ assert_eq!(ours, theirs, "copy .g1t/workflows/conformance.yml over .github/workflows/conformance.yml");
347+ // It runs only by hand, and reads nothing it could change.
348+ let workflow = read("conformance.yml");
349+ assert_eq!(workflow.triggers.iter().map(|t| t.event.as_str()).collect::<Vec<_>>(), ["workflow_dispatch"]);
350+ assert!(!ours.contains("secrets."));
351+}
+50−0
Loading the diff…
+1−319
Loading the diff…
+6−5
Loading the diff…
+121−1
Loading the diff…
+26−122
Loading the diff…
+1−0
Loading the diff…
+53−0
Loading the diff…
+3−0
Loading the diff…
+6−0
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…