g1t can be checked against GitHub. The Conformance workflow, the same file in `.g1t/workflows/` and `.github/workflows/` (a test keeps them identical), runs by hand on either; each run's report records which runner variables were set and of what kind, every key of the contexts and the event payload with its type, how the workspace is laid out, and the values outputs, `GITHUB_ENV` and `GITHUB_PATH`, multi-line outputs, `working-directory`, job outputs, the matrix, a cache round trip, an artifact round trip, `hashFiles` and the expression functions give. `node scripts/conformance-diff.mjs github.json g1t.json` lists what GitHub has that g1t lacks or has otherwise, and what only g1t has. It reads no secrets. CONTRIBUTING says how to run it, and CI runs its tests.
- Claude-Session:
- https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
9 files+487−00/9 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 |
| 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 | + | }); |
| 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 | + | } |
| 15 | 15 | "deploy": "npm run deploy -w @g1t/web", | |
| 16 | 16 | "test:deploy": "node --test \"scripts/deploy/*.test.mjs\"", | |
| 17 | 17 | "test:ops": "node --test \"scripts/ops/*.test.mjs\"", | |
| 18 | + | "test:conformance": "node --test \"conformance/*.test.mjs\"", | |
| 18 | 19 | "test:release": "node --test \"scripts/desktop-release.test.mjs\" \"scripts/mobile-release.test.mjs\"" | |
| 19 | 20 | }, | |
| 20 | 21 | "devDependencies": { |
| 1 | + | #!/usr/bin/env node | |
| 2 | + | // Compares what the Conformance workflow (.g1t/workflows/conformance.yml) | |
| 3 | + | // saw on GitHub with what it saw on g1t: each run's `conformance-report` | |
| 4 | + | // artifact, conformance.json. What GitHub has and g1t lacks, or has of | |
| 5 | + | // another type, and every check whose value differs, is a difference, and | |
| 6 | + | // the exit code is 1. What g1t has beyond GitHub is listed, as g1t's own. | |
| 7 | + | // | |
| 8 | + | // node scripts/conformance-diff.mjs github.json g1t.json | |
| 9 | + | ||
| 10 | + | import { readFileSync } from "node:fs"; | |
| 11 | + | ||
| 12 | + | /** The differences between `github`'s and `g1t`'s reports, by section. */ | |
| 13 | + | export function compare(github, g1t) { | |
| 14 | + | const found = { missing: [], different: [], extra: [] }; | |
| 15 | + | const mapping = (section, theirs = {}, ours = {}) => { | |
| 16 | + | for (const [key, value] of Object.entries(theirs)) { | |
| 17 | + | if (!(key in ours)) found.missing.push(`${section} ${key} (${value})`); | |
| 18 | + | else if (ours[key] !== value) found.different.push(`${section} ${key}: GitHub ${JSON.stringify(value)}, g1t ${JSON.stringify(ours[key])}`); | |
| 19 | + | } | |
| 20 | + | for (const [key, value] of Object.entries(ours)) { | |
| 21 | + | if (!(key in theirs)) found.extra.push(`${section} ${key} (${value})`); | |
| 22 | + | } | |
| 23 | + | }; | |
| 24 | + | mapping("variable", github.variables, g1t.variables); | |
| 25 | + | mapping("layout", github.layout, g1t.layout); | |
| 26 | + | for (const name of new Set([...Object.keys(github.contexts ?? {}), ...Object.keys(g1t.contexts ?? {})])) { | |
| 27 | + | mapping(`${name}.`, github.contexts?.[name], g1t.contexts?.[name]); | |
| 28 | + | } | |
| 29 | + | mapping("event", github.event ?? {}, g1t.event ?? {}); | |
| 30 | + | mapping("check", github.checks, g1t.checks); | |
| 31 | + | return found; | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { | |
| 35 | + | const [githubFile, g1tFile] = process.argv.slice(2); | |
| 36 | + | if (!githubFile || !g1tFile) { | |
| 37 | + | console.error("usage: node scripts/conformance-diff.mjs github.json g1t.json"); | |
| 38 | + | process.exit(2); | |
| 39 | + | } | |
| 40 | + | const read = (file) => JSON.parse(readFileSync(file, "utf8")); | |
| 41 | + | const found = compare(read(githubFile), read(g1tFile)); | |
| 42 | + | const section = (title, items) => { | |
| 43 | + | if (items.length === 0) return; | |
| 44 | + | console.log(`${title} (${items.length})`); | |
| 45 | + | for (const item of items) console.log(` ${item}`); | |
| 46 | + | }; | |
| 47 | + | section("On GitHub, not on g1t", found.missing); | |
| 48 | + | section("Different on g1t", found.different); | |
| 49 | + | section("Only on g1t", found.extra); | |
| 50 | + | const failing = found.missing.length + found.different.length; | |
| 51 | + | console.log(failing === 0 ? "g1t matches GitHub." : `${failing} differences from GitHub.`); | |
| 52 | + | process.exit(failing === 0 ? 0 : 1); | |
| 53 | + | } |