Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'
48 files+1630−58616 files not counted0/48 viewed
| 89 | 89 | run: npm test --workspaces --if-present | |
| 90 | 90 | - name: The deploy tool's tests | |
| 91 | 91 | run: npm run test:deploy | |
| 92 | + | - name: The conformance report's tests | |
| 93 | + | run: npm run test:conformance | |
| 92 | 94 | - name: The ops scripts' tests | |
| 93 | 95 | run: npm run test:ops | |
| 94 | 96 | - name: The manifest matches every wrangler.jsonc |
| 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 |
| 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 |
| 79 | 79 | - Look at what you changed in a browser. Screenshots catch what type | |
| 80 | 80 | checks do not. | |
| 81 | 81 | ||
| 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 | + | ||
| 82 | 110 | ## Deploying | |
| 83 | 111 | ||
| 84 | 112 | Pushes to `main` deploy themselves: `.g1t/workflows/deploy.yml` runs |
| 81 | 81 | ] | |
| 82 | 82 | ||
| 83 | 83 | [[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]] | |
| 84 | 96 | name = "aho-corasick" | |
| 85 | 97 | version = "1.1.5" | |
| 86 | 98 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 740 | 752 | ] | |
| 741 | 753 | ||
| 742 | 754 | [[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]] | |
| 743 | 767 | name = "ff" | |
| 744 | 768 | version = "0.14.0" | |
| 745 | 769 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 930 | 954 | "g1t-kit", | |
| 931 | 955 | "g1t-secrets", | |
| 932 | 956 | "hex", | |
| 957 | + | "rusqlite", | |
| 933 | 958 | "serde", | |
| 934 | 959 | "serde_json", | |
| 935 | 960 | "worker", | |
| ⋯ | |||
| 1315 | 1340 | ||
| 1316 | 1341 | [[package]] | |
| 1317 | 1342 | 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" | |
| 1318 | 1352 | version = "0.17.1" | |
| 1319 | 1353 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1320 | 1354 | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" | |
| ⋯ | |||
| 1324 | 1358 | ||
| 1325 | 1359 | [[package]] | |
| 1326 | 1360 | 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" | |
| 1327 | 1370 | version = "0.12.2" | |
| 1328 | 1371 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1329 | 1372 | checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" | |
| 1330 | 1373 | dependencies = [ | |
| 1331 | − | "hashbrown", | |
| 1374 | + | "hashbrown 0.17.1", | |
| 1332 | 1375 | ] | |
| 1333 | 1376 | ||
| 1334 | 1377 | [[package]] | |
| ⋯ | |||
| 1624 | 1667 | checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" | |
| 1625 | 1668 | dependencies = [ | |
| 1626 | 1669 | "equivalent", | |
| 1627 | − | "hashbrown", | |
| 1670 | + | "hashbrown 0.17.1", | |
| 1628 | 1671 | ] | |
| 1629 | 1672 | ||
| 1630 | 1673 | [[package]] | |
| ⋯ | |||
| 1755 | 1798 | checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" | |
| 1756 | 1799 | ||
| 1757 | 1800 | [[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]] | |
| 1758 | 1812 | name = "litemap" | |
| 1759 | 1813 | version = "0.8.3" | |
| 1760 | 1814 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 2444 | 2498 | ] | |
| 2445 | 2499 | ||
| 2446 | 2500 | [[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]] | |
| 2447 | 2515 | name = "russh" | |
| 2448 | 2516 | version = "0.63.3" | |
| 2449 | 2517 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 3502 | 3570 | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" | |
| 3503 | 3571 | ||
| 3504 | 3572 | [[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]] | |
| 3505 | 3579 | name = "version_check" | |
| 3506 | 3580 | version = "0.9.5" | |
| 3507 | 3581 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 3966 | 4040 | checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18" | |
| 3967 | 4041 | dependencies = [ | |
| 3968 | 4042 | "arraydeque", | |
| 3969 | − | "hashlink", | |
| 4043 | + | "hashlink 0.12.2", | |
| 3970 | 4044 | ] | |
| 3971 | 4045 | ||
| 3972 | 4046 | [[package]] | |
| ⋯ | |||
| 3993 | 4067 | ] | |
| 3994 | 4068 | ||
| 3995 | 4069 | [[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]] | |
| 3996 | 4090 | name = "zerofrom" | |
| 3997 | 4091 | version = "0.1.8" | |
| 3998 | 4092 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 127 | 127 | request.json().await.unwrap_or(Value::Null) | |
| 128 | 128 | } | |
| 129 | 129 | ||
| 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. | |
| 133 | 133 | async fn authenticate( | |
| 134 | 134 | request: &Request, | |
| 135 | 135 | services: &Services, | |
| 136 | 136 | ) -> Result<std::result::Result<Viewer, Response>> { | |
| 137 | 137 | 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)); | |
| 143 | 140 | }; | |
| 144 | 141 | let viewer: Viewer = g1t_kit::call( | |
| 145 | 142 | &services.identity, |
| 55 | 55 | Sha256::digest(token.as_bytes())[..8].iter().map(|byte| format!("{byte:02x}")).collect() | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 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. | |
| 59 | 62 | pub fn bearer(header: &str) -> Option<&str> { | |
| 60 | 63 | 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 | + | } | |
| 62 | 67 | _ => None, | |
| 63 | 68 | } | |
| 64 | 69 | } | |
| ⋯ | |||
| 133 | 138 | } | |
| 134 | 139 | ||
| 135 | 140 | #[test] | |
| 136 | − | fn only_bearer_tokens_are_read() { | |
| 141 | + | fn bearer_and_token_schemes_are_read() { | |
| 137 | 142 | assert_eq!(bearer("Bearer g1t_abc "), Some("g1t_abc")); | |
| 138 | 143 | 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); | |
| 139 | 148 | assert_eq!(bearer("Basic dXNlcjpwYXNz"), None); | |
| 140 | 149 | assert_eq!(bearer("Bearer "), None); | |
| 141 | 150 | assert_eq!(bearer(""), None); | |
| 273 | 273 | register with `--no-job-docker`. | |
| 274 | 274 | - **Status.** Not scheduled. | |
| 275 | 275 | ||
| 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 | + | ||
| 276 | 287 | ### No `gh` command in jobs | |
| 277 | 288 | ||
| 278 | 289 | The runner does not include the `gh` command, and pointing it at g1t | |
| ⋯ | |||
| 318 | 329 | library themselves. The library refuses to run against any server but | |
| 319 | 330 | github.com. `actions/upload-artifact`, `actions/download-artifact` and | |
| 320 | 331 | `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 | |
| 323 | 334 | entry under 128 MB in one request, and g1t takes at most 100 MB in one | |
| 324 | 335 | request, as for [pushes](#pushes-up-to-100-mb-each). The step warns and | |
| 325 | 336 | the job goes on; smaller and larger entries are saved. | |
| 66 | 66 | | 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). | | |
| 67 | 67 | | Composite actions | The same. | | |
| 68 | 68 | | 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. | | |
| 70 | 70 | | `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). | | |
| 71 | 71 | | `::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. | | |
| 72 | 72 | | 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). | | |
| 73 | 73 | | `secrets.*`, `vars.*`, `secrets.GITHUB_TOKEN` | The same. `secrets.G1T_TOKEN` is [the job's own token](#the-jobs-token); `GITHUB_TOKEN` is its alias. | | |
| 74 | 74 | | `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. | | |
| 75 | 75 | | `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). | | |
| 77 | 77 | | 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). | | |
| 78 | 78 | | sccache with `SCCACHE_GHA_ENABLED` | The same: its entries go to the repository's cache. See [caching Rust builds](#caching-rust-builds). | | |
| 79 | 79 | | `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). | | |
| ⋯ | |||
| 105 | 105 | the `pull_request` types `unassigned`, `locked` and `unlocked`, `edited` | |
| 106 | 106 | for a new title or description, or the `issues` types `unassigned`, | |
| 107 | 107 | `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. | |
| 108 | 111 | - **Background steps.** A step with `background: true`, or a `wait`, | |
| 109 | 112 | `wait-all`, `cancel` or `parallel` step, is refused: g1t does not run | |
| 110 | 113 | them yet. | |
| ⋯ | |||
| 243 | 246 | GitHub, a tag before a branch of the same name, and looks again at | |
| 244 | 247 | most every 5 minutes, so a tag that moves is followed within minutes. | |
| 245 | 248 | 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. | |
| 248 | 253 | ||
| 249 | 254 | While GitHub doesn't answer, a ref reads as the commit it last named, and | |
| 250 | 255 | the job says so in a warning; an action g1t keeps still downloads. An | |
| ⋯ | |||
| 613 | 618 | language images have both). In an image without them, such as Alpine, | |
| 614 | 619 | they run beside the container, on g1t's runner, with the same files, | |
| 615 | 620 | 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. | |
| 618 | 622 | ||
| 619 | 623 | ### Building and pushing images | |
| 620 | 624 | ||
| ⋯ | |||
| 783 | 787 | ||
| 784 | 788 | ## Actions built on the toolkit | |
| 785 | 789 | ||
| 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 | |
| 791 | 794 | `ACTIONS_RESULTS_URL`, and g1t answers the toolkit's requests from the | |
| 792 | 795 | repository's cache. | |
| 793 | 796 | ||
| 794 | 797 | - Their entries are the repository's, under [the cache's](#the-cache) | |
| 795 | 798 | 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. | |
| 800 | 803 | - An entry the toolkit sends whole, which it does below 128 MB, is not | |
| 801 | 804 | saved when it is over 100 MB, the most g1t takes in one request. The | |
| 802 | 805 | step warns and the job goes on. | |
| ⋯ | |||
| 990 | 993 | | --- | --- | | |
| 991 | 994 | | `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`) | | |
| 992 | 995 | | `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) | | |
| 994 | 997 | | `repository_visibility` | `public` or `private` | | |
| 995 | 998 | | `ref`, `ref_type`, `ref_protected`, `sha` | `refs/heads/main`, `branch`, `"true"`, the commit | | |
| 996 | 999 | | `head_ref`, `base_ref` | A pull request's branches | | |
| ⋯ | |||
| 1167 | 1170 | logs** shows or hides the times; your browser remembers which. | |
| 1168 | 1171 | - A download (the job's `log.txt` and the run's zip) has the time at the | |
| 1169 | 1172 | 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. | |
| 1170 | 1185 | - If the page cannot reach g1t for a moment, it says **Reconnecting to the | |
| 1171 | 1186 | log** and tries again, waiting longer each time up to half a minute. | |
| 1172 | 1187 | A tab you are not looking at stops asking, and catches up when you come | |
| ⋯ | |||
| 1397 | 1412 | always the default branch: see | |
| 1398 | 1413 | [pull requests into other branches](/guides/base-branches/). | |
| 1399 | 1414 | ||
| 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 | + | ||
| 1400 | 1460 | A pull request from outside the workspace may wait for approval before | |
| 1401 | 1461 | its workflows run: see [pull requests from outside](#pull-requests-from-outside). | |
| 1402 | 1462 | ||
| ⋯ | |||
| 1406 | 1466 | on anyone else's. `github.event.issue.requested_by` does the same for an | |
| 1407 | 1467 | issue g1t's agent filed. `sender` is whoever caused the event. | |
| 1408 | 1468 | ||
| 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 | + | ||
| 1409 | 1509 | ## Checks | |
| 1410 | 1510 | ||
| 1411 | 1511 | 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 | |
| 1413 | 1513 | person or an agent, and its runs report a check named after the workflow: | |
| 1414 | 1514 | a workflow with `name: CI` reports `CI`, with the status context | |
| 1415 | 1515 | `CI / pull_request` (the workflow's name and the event). Each of its jobs | |
| 208 | 208 | -d "{\"name\": \"coverage\", \"head_sha\": \"$GITHUB_SHA\", \"conclusion\": \"neutral\", \"output\": {\"title\": \"81% covered\", \"summary\": \"Up 2% from main.\"}}" | |
| 209 | 209 | ``` | |
| 210 | 210 | ||
| 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 | + | ||
| 211 | 216 | A pull request's runs from someone without the Write role get no token | |
| 212 | 217 | that can write, so they cannot report checks. | |
| 213 | 218 |
| 47 | 47 | -H "Authorization: Bearer $G1T_TOKEN" | |
| 48 | 48 | ``` | |
| 49 | 49 | ||
| 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 | + | ||
| 50 | 54 | Public data can be read without a token. A token that is not valid is | |
| 51 | 55 | rejected with `401` rather than treated as anonymous. Each endpoint's page | |
| 52 | 56 | says whether it needs a token. |
| 4 | 4 | * its errors and warnings marked. | |
| 5 | 5 | */ | |
| 6 | 6 | 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"; | |
| 8 | 8 | import { Link } from "react-router"; | |
| 9 | 9 | ||
| 10 | 10 | import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts"; | |
| ⋯ | |||
| 12 | 12 | import { Card } from "./ui/card"; | |
| 13 | 13 | import { Hint } from "./ui/hint"; | |
| 14 | 14 | 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"; | |
| 16 | 17 | import { type PollAnswer, nextPoll, retryAfterMs } from "../lib/log-poll"; | |
| 17 | 18 | ||
| 18 | 19 | /** | |
| ⋯ | |||
| 147 | 148 | ); | |
| 148 | 149 | } | |
| 149 | 150 | ||
| 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 }; | |
| 152 | 153 | ||
| 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) { | |
| 154 | 155 | const label = /^(Error|Warning|Notice): /; | |
| 155 | 156 | const text = line.text.replace(label, ""); | |
| 156 | 157 | const ansi = line.ansi?.replace(label, ""); | |
| 158 | + | const id = anchor ? `${anchor}:${number}` : undefined; | |
| 157 | 159 | 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 | + | )} | |
| 160 | 171 | {timestamps && ( | |
| 161 | 172 | <time dateTime={line.at} className="w-[24ch] shrink-0 select-none text-faint/70 tabular-nums"> | |
| 162 | 173 | {line.at ? shownTime(line.at) : ""} | |
| ⋯ | |||
| 171 | 182 | ); | |
| 172 | 183 | }, sameLine); | |
| 173 | 184 | ||
| 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. */ | |
| 175 | 186 | function sameLine(a: LogLineProps, b: LogLineProps): boolean { | |
| 176 | 187 | return ( | |
| 177 | 188 | a.number === b.number && | |
| 178 | 189 | a.query === b.query && | |
| 179 | 190 | 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 | |
| 184 | 194 | ); | |
| 185 | 195 | } | |
| 186 | 196 | ||
| 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 | + | ||
| 187 | 218 | /** | |
| 188 | 219 | * A step's log. With `query`, only the lines holding it (any case), groups | |
| 189 | 220 | * 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). | |
| 190 | 225 | */ | |
| 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); | |
| 196 | 343 | 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 | + | )} | |
| 201 | 357 | </div> | |
| 202 | 358 | ); | |
| 203 | − | } | |
| 359 | + | }); | |
| 204 | 360 | 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]! }} />} | |
| 227 | 365 | </div> | |
| 228 | 366 | ); | |
| 229 | 367 | } | |
| 230 | 368 | ||
| 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. */ | |
| 232 | 373 | export type JobLog = { | |
| 233 | − | byStep: Map<number, string>; | |
| 374 | + | byStep: Map<number, StepLog>; | |
| 234 | 375 | /** When a chunk of each step's last arrived, by this browser's clock. */ | |
| 235 | 376 | grewAt: Map<number, number>; | |
| 236 | 377 | /** The last ask failed, and the page is waiting to ask again. */ | |
| ⋯ | |||
| 244 | 385 | * `running` is whether one of the job's steps is running now. | |
| 245 | 386 | */ | |
| 246 | 387 | 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()); | |
| 248 | 392 | const [grewAt, setGrewAt] = useState<Map<number, number>>(new Map()); | |
| 249 | 393 | const [retrying, setRetrying] = useState(false); | |
| 250 | 394 | const after = useRef(0); | |
| ⋯ | |||
| 252 | 396 | runningNow.current = running; | |
| 253 | 397 | useEffect(() => { | |
| 254 | 398 | after.current = 0; | |
| 255 | − | setChunks([]); | |
| 399 | + | steps.current = new Map(); | |
| 400 | + | setByStep(new Map()); | |
| 256 | 401 | setGrewAt(new Map()); | |
| 257 | 402 | }, [url]); | |
| 258 | 403 | useEffect(() => { | |
| ⋯ | |||
| 283 | 428 | if (page.chunks.length > 0) { | |
| 284 | 429 | after.current = page.chunks[page.chunks.length - 1]!.seq; | |
| 285 | 430 | 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)); | |
| 287 | 433 | setGrewAt((known) => { | |
| 288 | 434 | const next = new Map(known); | |
| 289 | 435 | for (const chunk of page.chunks) next.set(chunk.step, now); | |
| ⋯ | |||
| 315 | 461 | document.removeEventListener("visibilitychange", shown); | |
| 316 | 462 | }; | |
| 317 | 463 | }, [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]); | |
| 323 | 464 | return { byStep, grewAt, retrying }; | |
| 324 | 465 | } | |
| 325 | 466 | ||
| 324 | 324 | earlier, | |
| 325 | 325 | canRerunWorkflows, | |
| 326 | 326 | settingsUrl, | |
| 327 | + | conflicting = false, | |
| 327 | 328 | error, | |
| 328 | 329 | loading = false, | |
| 329 | 330 | settled = false, | |
| ⋯ | |||
| 341 | 342 | canRerunWorkflows: boolean; | |
| 342 | 343 | /** Where the required checks are chosen, for those who may. */ | |
| 343 | 344 | 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; | |
| 344 | 350 | error?: string | null; | |
| 345 | 351 | /** The workflow runs' jobs are still being read: their rows wait, busy. */ | |
| 346 | 352 | loading?: boolean; | |
| ⋯ | |||
| 350 | 356 | */ | |
| 351 | 357 | settled?: boolean; | |
| 352 | 358 | }) { | |
| 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; | |
| 354 | 361 | ||
| 355 | 362 | // Every workflow row, so they can be counted the same way they are shown. | |
| 356 | 363 | const workflow: Standing[] = statuses.flatMap((status) => { | |
| ⋯ | |||
| 374 | 381 | ? `${pending} of ${total} ${total === 1 ? "check is" : "checks are"} still running` | |
| 375 | 382 | : total > 0 | |
| 376 | 383 | ? "All checks have passed" | |
| 377 | − | : "Checks"; | |
| 384 | + | : held | |
| 385 | + | ? "Workflows have not run" | |
| 386 | + | : "Checks"; | |
| 378 | 387 | const others = [ | |
| 379 | 388 | failed > 0 && `${failed} failed`, | |
| 380 | 389 | pending > 0 && `${pending} running`, | |
| ⋯ | |||
| 409 | 418 | <CircleX size={16} className="text-danger" /> | |
| 410 | 419 | ) : standing === "running" ? ( | |
| 411 | 420 | <LoaderCircle size={16} className="animate-spin text-warn" /> | |
| 421 | + | ) : held && total === 0 ? ( | |
| 422 | + | <CircleAlert size={16} className="text-warn" /> | |
| 412 | 423 | ) : ( | |
| 413 | 424 | <CircleCheck size={16} className="text-success" /> | |
| 414 | 425 | )} | |
| ⋯ | |||
| 416 | 427 | <div className="min-w-0 grow"> | |
| 417 | 428 | <p className="text-sm font-medium">{headline}</p> | |
| 418 | 429 | {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 | + | )} | |
| 419 | 437 | {!summary && !settled && settingsUrl && ( | |
| 420 | 438 | <p className="mt-0.5 text-xs text-muted"> | |
| 421 | 439 | <Link to={settingsUrl} className="text-fg hover:underline"> | |
| 3 | 3 | ||
| 4 | 4 | import type { JobLogText } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 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 | + | }); | |
| 7 | 112 | ||
| 8 | 113 | const LOG = ["Job: test", "##[group]Run cargo test", "cargo test --locked", "##[endgroup]", "test greet ... FAILED", "##[error]Process completed with exit code 101."].join("\n"); | |
| 9 | 114 |
| 58 | 58 | return lineOf("text", raw, at); | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 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 | + | ||
| 61 | 147 | /** Lines into blocks: plain lines, and groups that fold. Lines are numbered from 1, group markers aside. */ | |
| 62 | 148 | 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; | |
| 84 | 150 | } | |
| 85 | 151 | ||
| 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[] { | |
| 88 | 157 | const wanted = query.trim().toLowerCase(); | |
| 89 | 158 | 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); | |
| 96 | 164 | } | |
| 165 | + | if (log.tail && log.tail.line.text.toLowerCase().includes(wanted)) found.push(log.tail); | |
| 166 | + | searched.set(log, { query: wanted, found }); | |
| 97 | 167 | return found; | |
| 98 | 168 | } | |
| 99 | 169 | ||
| 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 | + | ||
| 100 | 210 | /** `text` cut where `query` (any case) appears, each piece marked as a match or not. */ | |
| 101 | 211 | export function highlight(text: string, query: string): { text: string; match: boolean }[] { | |
| 102 | 212 | const wanted = query.trim().toLowerCase(); |
| 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 | + | }); |
| 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 | + | } |
| 49 | 49 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu"; | |
| 50 | 50 | import { Hint } from "../../components/ui/hint"; | |
| 51 | 51 | import { useWorkflowReason } from "../../components/mirror"; | |
| 52 | − | import { lastStamp, searchLog } from "../../lib/log-lines"; | |
| 52 | + | import { type StepLog, searchStepLog } from "../../lib/log-lines"; | |
| 53 | 53 | import { quietWords } from "../../lib/log-poll"; | |
| 54 | 54 | import { listArtifacts, withLegacyArtifacts } from "../../lib/run-artifacts.server"; | |
| 55 | 55 | import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "../../lib/run-artifacts"; | |
| ⋯ | |||
| 140 | 140 | ||
| 141 | 141 | function StepRow({ | |
| 142 | 142 | step, | |
| 143 | − | text, | |
| 143 | + | log, | |
| 144 | 144 | defaultOpen, | |
| 145 | 145 | query = "", | |
| 146 | 146 | matches = 0, | |
| ⋯ | |||
| 148 | 148 | lastOutput = null, | |
| 149 | 149 | now, | |
| 150 | 150 | end, | |
| 151 | + | target = null, | |
| 151 | 152 | }: { | |
| 152 | 153 | step: StepState; | |
| 153 | − | text: string | undefined; | |
| 154 | + | log: StepLog | undefined; | |
| 154 | 155 | defaultOpen: boolean; | |
| 155 | 156 | /** A search of the job's log: only its matching lines are shown. */ | |
| 156 | 157 | query?: string; | |
| ⋯ | |||
| 162 | 163 | now: number; | |
| 163 | 164 | /** Marks the end of the running step's log, for following it. */ | |
| 164 | 165 | end?: Ref<HTMLDivElement>; | |
| 166 | + | /** The line of this step a link names (`#step:3:12`). */ | |
| 167 | + | target?: number | null; | |
| 165 | 168 | }) { | |
| 166 | 169 | const running = step.status === "in_progress"; | |
| 167 | 170 | const quiet = running && !query ? quietWords(step.startedAt ? new Date(step.startedAt).getTime() : null, lastOutput, now) : null; | |
| ⋯ | |||
| 181 | 184 | </span> | |
| 182 | 185 | </summary> | |
| 183 | 186 | <div className="border-t border-line bg-bg/60"> | |
| 184 | − | {text === undefined && !running ? ( | |
| 187 | + | {log === undefined && !running ? ( | |
| 185 | 188 | <p className="px-4 py-2 text-xs text-faint">{step.status === "queued" ? "Not started." : step.conclusion === "skipped" ? "Skipped." : "No output."}</p> | |
| 186 | 189 | ) : ( | |
| 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} /> | |
| 188 | 191 | )} | |
| 189 | 192 | {quiet && ( | |
| 190 | 193 | <p className="flex items-center gap-2 px-4 py-2 font-mono text-xs text-muted" aria-live="polite" suppressHydrationWarning> | |
| ⋯ | |||
| 198 | 201 | ); | |
| 199 | 202 | } | |
| 200 | 203 | ||
| 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 | + | ||
| 201 | 227 | /** Whether the viewer wants each log line's time shown, kept in this browser. */ | |
| 202 | 228 | function useTimestamps(): [boolean, (on: boolean) => void] { | |
| 203 | 229 | const [on, setOn] = useState(false); | |
| ⋯ | |||
| 478 | 504 | const [query, setQuery] = useState(""); | |
| 479 | 505 | const [timestamps, setTimestamps] = useTimestamps(); | |
| 480 | 506 | 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(); | |
| 482 | 509 | // The running step's log is followed while its end is in view, unless a | |
| 483 | 510 | // failed step is the one held open. | |
| 484 | 511 | const followed = !failed && running ? running.number : null; | |
| 485 | − | const { end, behind, jump } = useFollow(followed, runningText); | |
| 512 | + | const { end, behind, jump } = useFollow(followed, runningLog); | |
| 486 | 513 | // When the running step last printed: its last line's time, else when | |
| 487 | 514 | // its last chunk came. | |
| 488 | 515 | const lastOutput = useMemo(() => { | |
| 489 | 516 | if (!running) return null; | |
| 490 | − | const at = runningText ? lastStamp(runningText) : undefined; | |
| 517 | + | const at = runningLog?.lastAt; | |
| 491 | 518 | 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]); | |
| 494 | 521 | const searching = query.trim().length > 0; | |
| 495 | 522 | const setUp: StepState = { | |
| 496 | 523 | number: 0, | |
| ⋯ | |||
| 502 | 529 | }; | |
| 503 | 530 | // While searching, the steps with a match, each with how many. | |
| 504 | 531 | 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 | + | }) | |
| 506 | 536 | .filter(({ matches }) => !searching || matches > 0); | |
| 507 | 537 | const total = steps.reduce((sum, { matches }) => sum + matches, 0); | |
| 508 | 538 | return ( | |
| ⋯ | |||
| 605 | 635 | // Searching opens every step with a match; clearing it puts them back. | |
| 606 | 636 | key={`${step.number}:${searching}`} | |
| 607 | 637 | 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)} | |
| 610 | 640 | query={searching ? query : ""} | |
| 611 | 641 | matches={matches} | |
| 612 | 642 | timestamps={timestamps} | |
| 613 | 643 | now={now} | |
| 614 | 644 | lastOutput={step.number === running?.number ? lastOutput : null} | |
| 615 | 645 | end={step.number === followed ? end : undefined} | |
| 646 | + | target={step.number === target?.step ? target.line : null} | |
| 616 | 647 | /> | |
| 617 | 648 | ))} | |
| 618 | 649 | </Card> | |
| 677 | 677 | earlier={earlierChecks} | |
| 678 | 678 | canRerunWorkflows={canMerge} | |
| 679 | 679 | settingsUrl={canProtect ? `${base}/settings/branches` : null} | |
| 680 | + | conflicting={mergeable === "conflicting" && !noChecks} | |
| 680 | 681 | error={actionData?.action === "rerun-failed" || actionData?.action === "rerun-workflow" ? actionData.error : null} | |
| 681 | 682 | loading={loading} | |
| 682 | 683 | /> |
| 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 | + | } |
| 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 | + | }); |
| 104 | 104 | self.repository.split('/').next().unwrap_or_default() | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 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`]). | |
| 108 | 109 | 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 | + | } | |
| 110 | 128 | } | |
| 111 | 129 | ||
| 112 | 130 | /// The `github` context for a job. `token` is `github.token` (and | |
| ⋯ | |||
| 118 | 136 | "action_ref": "", | |
| 119 | 137 | "action_repository": "", | |
| 120 | 138 | "actor": self.actor, | |
| 121 | − | "actor_id": self.actor_id, | |
| 139 | + | "actor_id": self.actor_number(), | |
| 122 | 140 | "api_url": self.api_url, | |
| 123 | 141 | "base_ref": self.base_ref.clone().unwrap_or_default(), | |
| 124 | 142 | "env": "", | |
| ⋯ | |||
| 134 | 152 | "ref_protected": self.ref_name() == self.default_branch, | |
| 135 | 153 | "ref_type": self.ref_type(), | |
| 136 | 154 | "repository": self.repository, | |
| 137 | − | "repository_id": self.repository_id, | |
| 155 | + | "repository_id": self.repository_number(), | |
| 138 | 156 | "repository_owner": self.owner(), | |
| 139 | 157 | "repository_owner_id": self.owner_id(), | |
| 140 | 158 | "repositoryUrl": format!("{}/{}.git", self.server_url, self.repository), | |
| ⋯ | |||
| 164 | 182 | set("GITHUB_ACTIONS", "true".into()); | |
| 165 | 183 | set("G1T", "true".into()); | |
| 166 | 184 | set("GITHUB_ACTOR", self.actor.clone()); | |
| 167 | − | set("GITHUB_ACTOR_ID", self.actor_id.clone()); | |
| 185 | + | set("GITHUB_ACTOR_ID", self.actor_number()); | |
| 168 | 186 | set("GITHUB_API_URL", self.api_url.clone()); | |
| 169 | 187 | set("GITHUB_BASE_REF", self.base_ref.clone().unwrap_or_default()); | |
| 170 | 188 | set("GITHUB_EVENT_NAME", self.event_name.clone()); | |
| ⋯ | |||
| 177 | 195 | set("GITHUB_REF_PROTECTED", (self.ref_name() == self.default_branch).to_string()); | |
| 178 | 196 | set("GITHUB_REF_TYPE", self.ref_type().into()); | |
| 179 | 197 | set("GITHUB_REPOSITORY", self.repository.clone()); | |
| 180 | − | set("GITHUB_REPOSITORY_ID", self.repository_id.clone()); | |
| 198 | + | set("GITHUB_REPOSITORY_ID", self.repository_number()); | |
| 181 | 199 | set("GITHUB_REPOSITORY_OWNER", self.owner().to_owned()); | |
| 182 | 200 | set("GITHUB_REPOSITORY_OWNER_ID", self.owner_id()); | |
| 183 | 201 | set("GITHUB_RETENTION_DAYS", "14".into()); | |
| ⋯ | |||
| 202 | 220 | } | |
| 203 | 221 | } | |
| 204 | 222 | ||
| 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 | + | ||
| 205 | 257 | /// The owner's id in a webhook-shaped payload, `repository.owner.id`, a | |
| 206 | 258 | /// number or a string. | |
| 207 | 259 | pub fn owner_id(event: &Value) -> Option<String> { | |
| ⋯ | |||
| 301 | 353 | assert_eq!(vars["GITHUB_JOB"], "build"); | |
| 302 | 354 | assert_eq!(vars["RUNNER_OS"], "Linux"); | |
| 303 | 355 | 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"); | |
| 305 | 384 | } | |
| 306 | 385 | ||
| 307 | 386 | #[test] | |
| 337 | 337 | let job = base.jobs.iter().find(|j| j.id == "build").unwrap(); | |
| 338 | 338 | assert_eq!(job.runs_on, json!(["self-hosted", "docker"])); | |
| 339 | 339 | } | |
| 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 | + | } |
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…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…