From 123852d9c8e576ce895c63c7831209347cc7ccc8 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:30:20 -0400 Subject: [PATCH 1/9] ci: one composite setup action for every repo-owned workflow; the yaml lint gate runs for real; one loader behind the workflow pins --- .../actions/fetch-test-artifacts/action.yml | 2 +- .github/actions/setup/action.yml | 38 + .github/workflows/auto-fix.yml | 6 +- .github/workflows/auto-format.yml | 81 +- .github/workflows/checks.yml | 52 +- .github/workflows/copilot-setup-steps.yml | 6 +- .github/workflows/e2e-nightly.yml | 12 +- .github/workflows/nightly-fuzz.yml | 6 +- .github/workflows/nightly.yml | 24 +- .github/workflows/post-green.yml | 13 +- .github/workflows/update-release-pr.yml | 4 +- .github/workflows/update-release.yml | 17 +- package.json | 2 +- test/docs/checks-workflow.test.ts | 121 ++- test/docs/e2e-nightly-workflow.test.ts | 35 +- test/docs/npm-publish-workflows.test.ts | 60 +- test/docs/post-green-workflow.test.ts | 71 +- test/docs/repo-owned-workflows.test.ts | 716 ++++++++++++++++++ test/docs/workflow-loader.ts | 163 ++++ 19 files changed, 1117 insertions(+), 312 deletions(-) create mode 100644 .github/actions/setup/action.yml create mode 100644 test/docs/repo-owned-workflows.test.ts create mode 100644 test/docs/workflow-loader.ts diff --git a/.github/actions/fetch-test-artifacts/action.yml b/.github/actions/fetch-test-artifacts/action.yml index 0cfe706b..7a62e82e 100644 --- a/.github/actions/fetch-test-artifacts/action.yml +++ b/.github/actions/fetch-test-artifacts/action.yml @@ -1,6 +1,6 @@ # Restores the two fetched, gitignored test artifacts (trimmed OpenAPI spec, GraphQL schema) # from cache, fetching each on a miss: the ONE place an artifact is cached (the drift-tripwire -# nightlies fetch uncached by design). Needs setup-bun and `bun install` earlier in the job. +# nightlies fetch uncached by design). Needs ./.github/actions/setup (bun and the install) earlier in the job. name: Fetch the test artifacts description: Restore the trimmed OpenAPI spec and the GraphQL schema from cache, fetching each on a miss diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 00000000..d56082e9 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,38 @@ +# The bun setup and the dependency install of every repo-owned workflow job (checks.yml, post-green.yml, the release +# hooks, the nightlies, auto-fix, auto-format, copilot-setup-steps). The caller keeps its own checkout (ref, depth, +# and token differ per job) and its own setup-node where a step needs node. +# bun -> the version in .bun-version, never latest +# install -> bun install --frozen-lockfile --ignore-scripts: scripts.prepare installs lefthook, a git hook +# no runner uses, and no dependency here needs a lifecycle script (the whole gate runs without them) +# install: "false" -> bun alone, for a job that runs a bare .github/scripts entry or resolves the lockfile itself; +# any other value installs (GitHub compares without regard to letter case), so a typo can only +# add an install, never lose one +# yamllint: "true" -> the pinned yamllint behind `bun run lint:yaml`, which fails a CI run where it is missing +# Timeouts: every job of a repo-owned workflow carries timeout-minutes: 15 unless its comment says why it needs more. +name: Set up bun +description: The pinned bun, the locked dependencies unless told not to, and yamllint for the jobs that lint YAML + +inputs: + install: + description: '"false" sets up bun alone; anything else runs bun install --frozen-lockfile --ignore-scripts' + default: "true" + yamllint: + description: '"true" installs the pinned yamllint for lint:yaml' + default: "false" + +runs: + using: composite + steps: + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + - name: Install dependencies + if: inputs.install != 'false' + shell: bash + run: bun install --frozen-lockfile --ignore-scripts + # pipx ships on the ubuntu runner image with its bin directory on PATH; a venv of its own leaves the runner's + # python untouched. + - name: Install yamllint + if: inputs.yamllint == 'true' + shell: bash + run: pipx install yamllint==1.38.0 diff --git a/.github/workflows/auto-fix.yml b/.github/workflows/auto-fix.yml index 665c3799..f1e9d924 100644 --- a/.github/workflows/auto-fix.yml +++ b/.github/workflows/auto-fix.yml @@ -68,11 +68,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref }} persist-credentials: false - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version - - name: Install dependencies - run: bun install --frozen-lockfile --ignore-scripts + - uses: ./.github/actions/setup - name: Graduate upstream gaps octokit now ships shell: bash run: bun .github/scripts/graduate-upstream-gaps.ts diff --git a/.github/workflows/auto-format.yml b/.github/workflows/auto-format.yml index 447bc727..09ab6fd7 100644 --- a/.github/workflows/auto-format.yml +++ b/.github/workflows/auto-format.yml @@ -3,6 +3,9 @@ # commit to the PR branch, then removes the label. Generated once by # the fleet sync and never overwritten by a later # sync: adjust the format commands to this repository's tooling. +# +# Formatting runs PR-controlled code (the locked biome, the lint:fix script), so it happens in a job without a write +# token and crosses to the push job as a git patch; applying a patch executes nothing, as in auto-fix.yml. name: Auto Format @@ -13,42 +16,80 @@ on: permissions: contents: read +# One run per PR at a time: the label removed and re-applied while a run is pushing would start a second run against +# the same branch, and two formatting pushes race. The later run queues and formats the pushed head instead. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: false + jobs: format: - # Same-repo PRs only: GITHUB_TOKEN cannot push to a fork's branch, and - # PR-controlled tool configuration should not run next to write - # credentials (checkout below persists none; the push step scopes them). + # Same-repo PRs only: GITHUB_TOKEN cannot push to a fork's branch. if: github.event.label.name == 'fix-lint' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + changed: ${{ steps.format.outputs.changed }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + persist-credentials: false + # The install puts the locked biome in node_modules, so the format runs the gate's biome, not the newest one. + - uses: ./.github/actions/setup + - name: Format (biome) and stage the patch + id: format + run: | + bun run lint:fix + git add -A + git diff --cached --binary > "$RUNNER_TEMP/format.patch" + if [ -s "$RUNNER_TEMP/format.patch" ]; then + git diff --cached --stat + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "nothing to format" + echo "changed=false" >> "$GITHUB_OUTPUT" + fi + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: steps.format.outputs.changed == 'true' + with: + name: format-patch + path: ${{ runner.temp }}/format.patch + if-no-files-found: error + + # Runs whenever the format job ran, so the label comes off even after a failed format: the label is a one-shot + # request, and a broken run must not loop. + push: + needs: format + if: always() && needs.format.result != 'skipped' + runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: write pull-requests: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: needs.format.outputs.changed == 'true' with: ref: ${{ github.event.pull_request.head.ref }} persist-credentials: false - - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - - name: Format (biome) - run: bun x @biomejs/biome check --write . - - - name: Commit and push changes + # The patch lands OUTSIDE the checkout so nothing in the work tree can shadow or stage it. + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + if: needs.format.outputs.changed == 'true' + with: + name: format-patch + path: ${{ runner.temp }}/format + - name: Commit and push the formatting + if: needs.format.outputs.changed == 'true' env: GH_TOKEN: ${{ github.token }} HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | - if git diff --quiet; then - echo "nothing to format" - else - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "style: apply automated formatting" - git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:"$HEAD_REF" - fi - # The label is a one-shot request: remove it even when a step failed, - # so a broken run cannot loop. + git apply --index --binary "$RUNNER_TEMP/format/format.patch" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit --no-verify -m "style: apply automated formatting" + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:"$HEAD_REF" - name: Remove the fix-lint label if: always() env: diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index e5a24db5..1f1fc676 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -12,18 +12,19 @@ on: jobs: check: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: ./.github/actions/setup with: - bun-version-file: .bun-version - - name: Install dependencies - run: bun install --frozen-lockfile + yamllint: "true" # bun test loads the fetched, gitignored OpenAPI spec and GraphQL # schema; the composite restores each from cache or fetches on a miss. - uses: ./.github/actions/fetch-test-artifacts - name: Lint (biome) run: bun run lint + - name: Lint (yaml) + run: bun run lint:yaml - name: Lint (architecture) run: bun run lint:arch - name: Typecheck @@ -50,9 +51,9 @@ jobs: with: # The newest release merge can be arbitrarily far behind HEAD. fetch-depth: 0 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: ./.github/actions/setup with: - bun-version-file: .bun-version + install: "false" - name: Confirm last-release-sha matches the newest release merge run: bun .github/scripts/release-pipeline.ts boundary-check @@ -63,21 +64,18 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: ./.github/actions/setup with: - bun-version-file: .bun-version + install: "false" - name: Require the release PR to carry this cycle's anchor run: bun .github/scripts/release-pipeline.ts anchor-check schema-check: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version - - name: Install dependencies - run: bun install --frozen-lockfile + - uses: ./.github/actions/setup - name: Regenerate the schema and compare run: bun run build:check @@ -87,15 +85,14 @@ jobs: # than the floor, so the PATH node is 24 and the floor's binary is handed to the consumer alone. package-smoke: runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: node-version: ["22.14", "24"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version + - uses: ./.github/actions/setup - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node-version }} @@ -104,8 +101,6 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - - name: Install dependencies - run: bun install --frozen-lockfile - name: Build, pack, install, import, and typecheck the library run: bun .github/scripts/package-smoke.ts @@ -113,13 +108,10 @@ jobs: # exception never sets the result output. self-check: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version - - name: Install dependencies - run: bun install --frozen-lockfile --ignore-scripts + - uses: ./.github/actions/setup - name: Build the action bundle run: bun run build:bundle - name: Run settings-as-code (check mode) @@ -142,18 +134,15 @@ jobs: # to diff against, so it runs the full corpus. e2e-smoke: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version + - uses: ./.github/actions/setup - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - - name: Install dependencies - run: bun install --frozen-lockfile - name: Select sections from the diff id: select env: @@ -188,18 +177,15 @@ jobs: # the selector finds nothing settings-related in the diff. endpoint-coverage: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version + - uses: ./.github/actions/setup - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - - name: Install dependencies - run: bun install --frozen-lockfile # Only the selector's "none" answer matters here. It runs BEFORE the spec cache/fetch so a PR that selects # nothing skips the download too. - name: Check whether the diff can affect route coverage diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index f3ef7f28..e7c8367e 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -21,10 +21,8 @@ permissions: jobs: copilot-setup-steps: runs-on: ubuntu-latest + # The most Copilot accepts for this job (its documented ceiling); the setup itself takes seconds. timeout-minutes: 59 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - - name: Install dependencies (bun) - run: bun install --frozen-lockfile + - uses: ./.github/actions/setup diff --git a/.github/workflows/e2e-nightly.yml b/.github/workflows/e2e-nightly.yml index e48b19eb..04dc1bc1 100644 --- a/.github/workflows/e2e-nightly.yml +++ b/.github/workflows/e2e-nightly.yml @@ -1,3 +1,8 @@ +# The nightly run of the whole curated e2e corpus (test/e2e/run.ts) plus the endpoint-coverage tripwire, against an +# OpenAPI spec fetched fresh: PR CI restores a cached spec for speed, so this fetch is where upstream drift surfaces. +# A red night files or updates one tracking issue through the fleet's fuzz-issue action; a green one closes it. +# Repo-owned, never overwritten by sync. + name: E2E Nightly on: @@ -18,16 +23,13 @@ concurrency: jobs: nightly: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version + - uses: ./.github/actions/setup - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - - name: Install dependencies - run: bun install --frozen-lockfile # The trimmed OpenAPI spec is a fetched, gitignored artifact. The nightly # ALWAYS fetches it (no cache) on purpose: the fetch is the drift tripwire. diff --git a/.github/workflows/nightly-fuzz.yml b/.github/workflows/nightly-fuzz.yml index 94c1e1e5..689500b9 100644 --- a/.github/workflows/nightly-fuzz.yml +++ b/.github/workflows/nightly-fuzz.yml @@ -52,14 +52,10 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version + - uses: ./.github/actions/setup - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - - name: Install dependencies - run: bun install --frozen-lockfile # The trimmed OpenAPI spec is a fetched, gitignored artifact the mock # validates responses against. Always fetched fresh (no cache), same as diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 3149cbff..eeac86ca 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -30,15 +30,11 @@ permissions: jobs: checks: runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version - - name: Install dependencies - id: install - run: bun install --frozen-lockfile --ignore-scripts + - uses: ./.github/actions/setup + id: setup # PR CI proves USED_PATHS against the pinned descriptor; this probe # re-cuts against upstream HEAD (deliberately bypassing the cached @@ -74,12 +70,12 @@ jobs: fi # always() so a red spec probe cannot mask this one (the job still - # fails if either step failed); gated on install because there is - # nothing to probe when setup itself broke. --no-save keeps the + # fails if either step failed); gated on the setup step because there + # is nothing to probe when setup itself broke. --no-save keeps the # lockfile and package.json untouched; the upgraded node_modules stays # ambient for the rest of this job, so keep later steps out of it. - name: Probe the latest @octokit/types - if: ${{ !cancelled() && steps.install.outcome == 'success' }} + if: ${{ !cancelled() && steps.setup.outcome == 'success' }} run: | bun add --no-save --ignore-scripts @octokit/types@latest log="$(mktemp)" @@ -111,12 +107,14 @@ jobs: # probe needs a clean tree to re-resolve from. float-canary: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + # The gate below runs lint:yaml; the from-scratch resolve is this job's install. + - uses: ./.github/actions/setup with: - bun-version-file: .bun-version + install: "false" + yamllint: "true" # Delete before resolving, exactly like the Dependabot workflow: bun # keeps a stale lockfile as a valid resolution, and only a # from-scratch resolve floats what that workflow would float. diff --git a/.github/workflows/post-green.yml b/.github/workflows/post-green.yml index d8f9c06a..ce4c7b60 100644 --- a/.github/workflows/post-green.yml +++ b/.github/workflows/post-green.yml @@ -59,14 +59,11 @@ jobs: echo "proceed=false" >> "$GITHUB_OUTPUT" fi rm -f probe.err - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: ./.github/actions/setup if: steps.token.outputs.proceed == 'true' - with: - bun-version-file: .bun-version - name: Build the bundle and the library if: steps.token.outputs.proceed == 'true' run: | - bun install --frozen-lockfile --ignore-scripts bun run build:bundle bun run build:lib - name: Append this commit's packaged commit to build and point latest at the newest main source @@ -112,10 +109,8 @@ jobs: # The version counts main's commits under this one and the verdict places published sources by ancestry; a shallow checkout can do neither. fetch-depth: 0 persist-credentials: false - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: ./.github/actions/setup if: steps.oidc.outputs.proceed == 'true' - with: - bun-version-file: .bun-version # registry-url writes the .npmrc npm publishes through; with no # NODE_AUTH_TOKEN the action leaves a placeholder there, which npm's # OIDC exchange replaces. @@ -140,9 +135,7 @@ jobs: fi - name: Build the library if: steps.oidc.outputs.proceed == 'true' - run: | - bun install --frozen-lockfile --ignore-scripts - bun run build:lib + run: bun run build:lib # scripts.prepare is dropped from the published manifest: it installs # lefthook, a devDependency the tarball does not carry, and npm blocks # install scripts from a provenance-attested package anyway. diff --git a/.github/workflows/update-release-pr.yml b/.github/workflows/update-release-pr.yml index b7c5caeb..5dd36652 100644 --- a/.github/workflows/update-release-pr.yml +++ b/.github/workflows/update-release-pr.yml @@ -28,8 +28,8 @@ jobs: contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: ./.github/actions/setup with: - bun-version-file: .bun-version + install: "false" - name: Anchor the boundary inside the release PR branch run: bun .github/scripts/release-pipeline.ts anchor diff --git a/.github/workflows/update-release.yml b/.github/workflows/update-release.yml index 2aae2835..afac819e 100644 --- a/.github/workflows/update-release.yml +++ b/.github/workflows/update-release.yml @@ -65,12 +65,9 @@ jobs: ref: ${{ steps.source.outputs.sha }} # The script judges whether the build tip's source lies on main; a shallow checkout cannot answer that. fetch-depth: 0 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version + - uses: ./.github/actions/setup - name: Build the bundle and the schema run: | - bun install --frozen-lockfile --ignore-scripts bun run build # The schema asset and the raw URLs at the tags must serve the bytes committed at the merge commit; a # divergent regeneration (generator drift, or a compromised generator) stops the release here. @@ -102,9 +99,9 @@ jobs: contents: write # read-only in spirit; write is what makes drafts visible to gh steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: ./.github/actions/setup with: - bun-version-file: .bun-version + install: "false" - name: Confirm the published refs carry the build outputs env: TAG: ${{ inputs.tag }} @@ -155,9 +152,7 @@ jobs: with: ref: ${{ needs.package-release.outputs.source-sha }} persist-credentials: false - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version + - uses: ./.github/actions/setup # registry-url writes the .npmrc npm publishes through; with no # NODE_AUTH_TOKEN the action leaves a placeholder there, which npm's # OIDC exchange replaces. @@ -179,9 +174,7 @@ jobs: exit 1 fi - name: Build the library - run: | - bun install --frozen-lockfile --ignore-scripts - bun run build:lib + run: bun run build:lib # scripts.prepare is dropped from the published manifest, as in # post-green.yml's publish-next. # The verdict holds the built package.json to the tag and reads the diff --git a/package.json b/package.json index b7c74cd5..0365ed8a 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "check": "bun run lint && bun run lint:yaml && bun run lint:arch && bun run check:compat && bun run typecheck && bun run knip && bun run test && bun run build:check", "check:compat": "bun .github/scripts/check-compat-markers.ts", "lint": "bun x biome ci --error-on-warnings .", - "lint:yaml": "if command -v yamllint >/dev/null 2>&1; then git ls-files -z '*.yml' '*.yaml' | xargs -0 yamllint -s; else echo 'lint:yaml: yamllint not installed, skipping (CI enforces it; install with pip install yamllint or brew install yamllint)'; fi", + "lint:yaml": "if command -v yamllint >/dev/null 2>&1; then git ls-files -z '*.yml' '*.yaml' | xargs -0 yamllint -s; elif [ -n \"$CI\" ]; then echo 'lint:yaml: yamllint is missing on this runner; the job needs ./.github/actions/setup with yamllint: \"true\"' >&2; exit 1; else echo 'lint:yaml: yamllint not installed, skipping (CI runs it; install with pip install yamllint or brew install yamllint)'; fi", "lint:fix": "bun x biome check --write .", "lint:arch": "bun .github/scripts/arch-lint.ts", "lint:package": "bun x publint --strict && bun x attw --pack . --profile esm-only", diff --git a/test/docs/checks-workflow.test.ts b/test/docs/checks-workflow.test.ts index 6789d60f..3010379e 100644 --- a/test/docs/checks-workflow.test.ts +++ b/test/docs/checks-workflow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { readdirSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; import { @@ -9,9 +9,21 @@ import { } from "../../.github/scripts/changed-sections.js"; import { RELEASE_PR_BRANCH_PREFIX } from "../../.github/scripts/release-pipeline.js"; import { headRefPrefixes, headRefPrefixesIn } from "./head-ref.js"; +import { + type CompositeAction, + executedLines, + installs, + ROOT, + readAction, + readWorkflow, + SETUP_USES, + type Step, + setupInstalls, + type Workflow, + workflowFiles, + workflowText, +} from "./workflow-loader.js"; -const ROOT = join(import.meta.dir, "..", ".."); -const WORKFLOWS_DIR = join(ROOT, ".github", "workflows"); const COMPOSITE_DIR = ".github/actions/fetch-test-artifacts"; const COMPOSITE_USES = `./${COMPOSITE_DIR}`; const PATHS_TS = "test/e2e/openapi/paths.ts"; @@ -125,22 +137,6 @@ const LOADERS: readonly Loader[] = [ ]; const [SUITE, HARNESS] = LOADERS as [Loader, Loader]; -interface Step { - id?: string; - uses?: string; - run?: string; - shell?: string; - if?: string; - "continue-on-error"?: boolean; - with?: Record; -} -interface Workflow { - jobs: Record; -} -interface CompositeAction { - runs: { using?: string; steps?: Step[] }; -} - /** True when a cache `path` entry (a file, a directory, or a glob) takes in `file`. */ function pathEntryCovers(entry: string, file: string): boolean { return ( @@ -174,16 +170,6 @@ function cacheKeyOf(step: Step, path: string): string { return key as string; } -function readWorkflow(file: string): Workflow { - return parseYaml(readFileSync(join(WORKFLOWS_DIR, file), "utf8")) as Workflow; -} - -function readComposite(): CompositeAction { - return parseYaml( - readFileSync(join(ROOT, COMPOSITE_DIR, "action.yml"), "utf8"), - ) as CompositeAction; -} - /** The quoted file patterns inside the key's hashFiles(...) call. */ function hashFilesPatterns(key: string): string[] { const match = key.match(/hashFiles\(([^)]*)\)/); @@ -306,33 +292,6 @@ function supports(step: Step, provider: Step): boolean { ); } -/** The run scalar's lines with every heredoc body (`< /^\s*bun install(?:\s|$)/.test(line) && !line.includes("||"), - ); -} - function runsFetch(run: string | undefined, fetchScript: string): boolean { const token = new RegExp(`(?:^|[\\s!(;&|])bun ${escapeRegExp(fetchScript)}(?=[\\s;)&|]|$)`); return executedLines(run ?? "").some((line) => !line.trim().startsWith("#") && token.test(line)); @@ -383,13 +342,13 @@ function expectArtifactsProvided(where: string, steps: Step[]): void { const provider = steps[providerIdx] as Step; const before = steps.slice(0, providerIdx); expect( - before.some( - (step) => (step.uses ?? "").startsWith("oven-sh/setup-bun@") && supports(step, provider), - ), - `${where}: a reliable oven-sh/setup-bun must precede the ${artifact.label} provider (its fetch runs under bun)`, + before.some((step) => step.uses === SETUP_USES && supports(step, provider)), + `${where}: a reliable ${SETUP_USES} must precede the ${artifact.label} provider (its fetch runs under bun)`, ).toBe(true); expect( - before.some((step) => installs(step.run) && supports(step, provider)), + before.some( + (step) => (installs(step.run) || setupInstalls(step)) && supports(step, provider), + ), `${where}: a reliable bun install must precede the ${artifact.label} provider (its fetch imports installed packages)`, ).toBe(true); for (const [loader, loaderIdx] of consumers) { @@ -448,8 +407,8 @@ function expectKeyPinned(key: string, artifact: FetchedArtifact): void { } describe("the fetch-test-artifacts composite", () => { - const action = readComposite(); - const steps = () => readComposite().runs.steps ?? []; + const action = readAction(COMPOSITE_DIR); + const steps = () => readAction(COMPOSITE_DIR).runs.steps ?? []; const cacheOf = (artifact: FetchedArtifact) => steps().find((step) => cachesArtifact(step, artifact.path)) as Step; const keyOf = (artifact: FetchedArtifact) => cacheKeyOf(cacheOf(artifact), artifact.path); @@ -641,7 +600,7 @@ describe("the fetch-test-artifacts composite", () => { }); describe("fetched test artifacts across workflows", () => { - const files = readdirSync(WORKFLOWS_DIR).filter((f) => /\.ya?ml$/.test(f)); + const files = workflowFiles(); const workflows = files.map((file) => ({ file, wf: readWorkflow(file) })); // Pinned: a derivation that silently found nothing would pass every guard below vacuously. @@ -699,9 +658,11 @@ describe("fetched test artifacts across workflows", () => { }); const CANARY = "nightly.yml#float-canary"; + const CHECK = "checks.yml#check"; const E2E_NIGHTLY = "e2e-nightly.yml#nightly"; const COVERAGE = "checks.yml#endpoint-coverage"; const canary = () => readWorkflow("nightly.yml").jobs["float-canary"]?.steps ?? []; + const check = () => readWorkflow("checks.yml").jobs.check?.steps ?? []; const coverage = () => readWorkflow("checks.yml").jobs["endpoint-coverage"]?.steps ?? []; const ungated = ({ if: _, ...step }: Step): Step => step; const e2eNightly = () => readWorkflow("e2e-nightly.yml").jobs.nightly?.steps ?? []; @@ -714,8 +675,8 @@ describe("fetched test artifacts across workflows", () => { const isInstall = (step: Step) => installs(step.run); const onInstall = (steps: Step[], patch: (step: Step) => Step) => steps.map((step) => (isInstall(step) ? patch(step) : step)); - const onSetupBun = (steps: Step[], patch: (step: Step) => Step) => - steps.map((step) => ((step.uses ?? "").startsWith("oven-sh/setup-bun@") ? patch(step) : step)); + const onSetup = (steps: Step[], patch: (step: Step) => Step) => + steps.map((step) => (step.uses === SETUP_USES ? patch(step) : step)); const moved = (steps: Step[], matches: (step: Step) => boolean, to: "first" | "last") => { const [step] = steps.splice(steps.findIndex(matches), 1); return to === "first" ? [step as Step, ...steps] : [...steps, step as Step]; @@ -781,16 +742,16 @@ describe("fetched test artifacts across workflows", () => { /nightly\.yml#float-canary fetches the GraphQL schema directly; a cached loading job restores it through/, ], [ - "a composite step before setup-bun", + "a composite step before the setup", CANARY, () => moved(canary(), isComposite, "first"), - /reliable oven-sh\/setup-bun must precede the trimmed OpenAPI spec provider/, + /reliable \.\/\.github\/actions\/setup must precede the trimmed OpenAPI spec provider/, ], [ - "a setup-bun allowed to fail", + "a setup allowed to fail", CANARY, - () => onSetupBun(canary(), (step) => ({ ...step, "continue-on-error": true })), - /reliable oven-sh\/setup-bun must precede/, + () => onSetup(canary(), (step) => ({ ...step, "continue-on-error": true })), + /reliable \.\/\.github\/actions\/setup must precede/, ], [ "a composite step before the install", @@ -798,6 +759,22 @@ describe("fetched test artifacts across workflows", () => { () => moved(canary(), isInstall, "last"), /reliable bun install must precede the trimmed OpenAPI spec provider/, ], + [ + "a setup whose install is switched off where no run step installs", + CHECK, + () => onSetup(check(), (step) => ({ ...step, with: { ...step.with, install: "false" } })), + /checks\.yml#check: a reliable bun install must precede the trimmed OpenAPI spec provider/, + ], + [ + "a setup whose install is an expression the pin cannot read", + CHECK, + () => + onSetup(check(), (step) => ({ + ...step, + with: { ...step.with, install: `\${{ 'true' }}` }, + })), + /checks\.yml#check: a reliable bun install must precede the trimmed OpenAPI spec provider/, + ], [ "a duplicated composite step", CANARY, @@ -913,7 +890,7 @@ function expectReleasePrefixes(wf: Workflow): void { } describe("checks.yml release PR branch spelling", () => { - const text = readFileSync(join(WORKFLOWS_DIR, "checks.yml"), "utf8"); + const text = workflowText("checks.yml"); // Workflows cannot import the constant, so the head_ref conditions spell it by hand; a drifted spelling skips the anchor-check on every release PR // instead of failing there. diff --git a/test/docs/e2e-nightly-workflow.test.ts b/test/docs/e2e-nightly-workflow.test.ts index 1a09fdcf..66634223 100644 --- a/test/docs/e2e-nightly-workflow.test.ts +++ b/test/docs/e2e-nightly-workflow.test.ts @@ -4,39 +4,17 @@ */ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { parse as parseYaml } from "yaml"; +import { readWorkflow } from "./workflow-loader.js"; -const ROOT = join(import.meta.dir, "..", ".."); const FUZZ_ISSUE_ACTION = "Vivswan/repo-platform/actions/fuzz-issue@stable"; -interface Step { - name?: string; - id?: string; - run?: string; - uses?: string; - if?: string; - env?: Record; - with?: Record; -} -interface Workflow { - on?: Record; - permissions?: Record; - jobs: Record }>; -} - -function workflow(file: string): Workflow { - return parseYaml(readFileSync(join(ROOT, ".github", "workflows", file), "utf8")) as Workflow; -} - describe.each([ // The run_attempt suffix: upload-artifact refuses a duplicate name, so a re-run attempt would upload nothing // and the filed issue would point at an artifact that never existed. ["e2e-nightly.yml", "nightly", "e2e-fuzz", `e2e-artifacts-\${{ github.run_attempt }}`], ["nightly-fuzz.yml", "fuzz", "fuzz-nightly", `fuzz-failures-\${{ github.run_attempt }}`], ])("%s issue + auto-assign path", (file, job, label, artifactName) => { - const wf = workflow(file); + const wf = readWorkflow(file); const steps = wf.jobs[job]?.steps ?? []; const filers = steps.filter((s) => s.uses === FUZZ_ISSUE_ACTION); @@ -100,16 +78,11 @@ describe.each([ }); describe("auto-assign.yml caller forwards the dispatched issue", () => { - const wf = parseYaml( - readFileSync(join(ROOT, ".github", "workflows", "auto-assign.yml"), "utf8"), - ) as Workflow; + const wf = readWorkflow("auto-assign.yml"); test("workflow_dispatch declares issue as an optional input and the reusable call forwards it", () => { - const dispatch = wf.on?.workflow_dispatch as - | { inputs?: Record } - | undefined; // The nightly filer always passes a number, and a bare dispatch must still run the full sweep. - expect(dispatch?.inputs?.issue).toMatchObject({ required: false, default: "" }); + expect(wf.on.workflow_dispatch?.inputs?.issue).toMatchObject({ required: false, default: "" }); expect(String(wf.jobs["auto-assign"]?.with?.issue)).toContain("inputs.issue"); }); }); diff --git a/test/docs/npm-publish-workflows.test.ts b/test/docs/npm-publish-workflows.test.ts index 1b73e3b2..d41dafb5 100644 --- a/test/docs/npm-publish-workflows.test.ts +++ b/test/docs/npm-publish-workflows.test.ts @@ -12,39 +12,21 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { parse as parseYaml } from "yaml"; +import { type Job, readWorkflow, type Step } from "./workflow-loader.js"; -const ROOT = join(import.meta.dir, "..", ".."); - -interface Step { - name?: string; - id?: string; - uses?: string; - if?: string; - run?: string; - env?: Record; - with?: Record; -} -interface Job { - if?: string; - needs?: string[]; - concurrency?: { group?: string; queue?: string; "cancel-in-progress"?: boolean }; - permissions?: Record; - steps: Step[]; -} -interface Workflow { - jobs: Record; -} - -function workflow(file: string): Workflow { - return parseYaml(readFileSync(join(ROOT, ".github", "workflows", file), "utf8")) as Workflow; -} +/** A job that runs steps (the publishers are never reusable-workflow calls). */ +type RunJob = Job & { steps: Step[] }; function must(value: T | undefined, what: string): T { if (value === undefined) throw new Error(`no ${what}`); return value; } +function runJob(job: Job | undefined, what: string): RunJob { + const found = must(job, what); + return { ...found, steps: must(found.steps, `${what} steps`) }; +} + /** Every string a step hands the runner beyond its script: env values, with values, the gate. */ function stepInputs(step: Step): string[] { return [ @@ -54,12 +36,12 @@ function stepInputs(step: Step): string[] { ]; } -const stepNamed = (job: Job, name: string): Step => +const stepNamed = (job: RunJob, name: string): Step => must( job.steps.find((step) => step.name === name), `step ${name}`, ); -const setupNode = (job: Job): Step => +const setupNode = (job: RunJob): Step => must( job.steps.find((step) => step.uses?.startsWith("actions/setup-node@")), "setup-node step", @@ -117,7 +99,7 @@ const EXPECTED: Contract = { ], }; -function contractOf(next: Job, stable: Job): Contract { +function contractOf(next: RunJob, stable: RunJob): Contract { const probe = next.steps.findIndex((step) => step.id === "oidc"); if (probe < 0) throw new Error("publish-next has no oidc probe"); const publishLines = (step: Step): string[] => @@ -130,7 +112,7 @@ function contractOf(next: Job, stable: Job): Contract { nextPermissions: next.permissions, repositoryGuards: [next.if, stable.if], lanes: [next.concurrency, stable.concurrency], - stableNeeds: [...(stable.needs ?? [])].sort(), + stableNeeds: [stable.needs ?? []].flat().sort(), ungatedNextSteps: next.steps .slice(probe + 1) .filter((step) => step.if !== OIDC_GATE && step.if !== PUBLISHED_GATE) @@ -153,16 +135,14 @@ function contractOf(next: Job, stable: Job): Contract { } describe("the npm publish jobs", () => { - const postGreen = workflow("post-green.yml"); - const updateRelease = workflow("update-release.yml"); - const next = must(postGreen.jobs["publish-next"], "publish-next job"); - const stable = must(updateRelease.jobs["publish-npm"], "publish-npm job"); + const next = runJob(readWorkflow("post-green.yml").jobs["publish-next"], "publish-next job"); + const stable = runJob(readWorkflow("update-release.yml").jobs["publish-npm"], "publish-npm job"); test("both publish through OIDC alone, with the same guard and build, and publish-next skips whole without a token", () => { expect(contractOf(next, stable)).toEqual(EXPECTED); }); - const REGRESSIONS: Array<[string, (next: Job, stable: Job) => void, keyof Contract]> = [ + const REGRESSIONS: Array<[string, (next: RunJob, stable: RunJob) => void, keyof Contract]> = [ [ "a stable publish without the OIDC grant", (_next, stable) => (stable.permissions = { contents: "read" }), @@ -240,7 +220,7 @@ describe("the npm publish jobs", () => { "a library build that diverged between the jobs", (_next, stable) => { const build = stepNamed(stable, "Build the library"); - build.run = build.run?.replace("--ignore-scripts", ""); + build.run = build.run?.replace("build:lib", "build"); }, "sameBuild", ], @@ -321,7 +301,7 @@ function runStep( } describe("the OIDC probe under bash", () => { - const next = must(workflow("post-green.yml").jobs["publish-next"], "publish-next job"); + const next = runJob(readWorkflow("post-green.yml").jobs["publish-next"], "publish-next job"); const run = must(must(next.steps[0], "probe step").run, "probe run"); test("a runner that minted a token URL proceeds silently", () => { @@ -342,7 +322,7 @@ describe("the OIDC probe under bash", () => { }); describe("the npm floor guard under bash", () => { - const stable = must(workflow("update-release.yml").jobs["publish-npm"], "publish-npm job"); + const stable = runJob(readWorkflow("update-release.yml").jobs["publish-npm"], "publish-npm job"); const run = must( stepNamed(stable, "Require an npm that publishes through OIDC").run, "guard run", @@ -398,8 +378,8 @@ describe("the npm floor guard under bash", () => { }); describe("the publish blocks under bash", () => { - const next = must(workflow("post-green.yml").jobs["publish-next"], "publish-next job"); - const stable = must(workflow("update-release.yml").jobs["publish-npm"], "publish-npm job"); + const next = runJob(readWorkflow("post-green.yml").jobs["publish-next"], "publish-next job"); + const stable = runJob(readWorkflow("update-release.yml").jobs["publish-npm"], "publish-npm job"); const nextRun = must( stepNamed(next, "Publish the pre-release under the next dist-tag").run, "next publish run", diff --git a/test/docs/post-green-workflow.test.ts b/test/docs/post-green-workflow.test.ts index 8f23c726..21ab754a 100644 --- a/test/docs/post-green-workflow.test.ts +++ b/test/docs/post-green-workflow.test.ts @@ -6,42 +6,13 @@ import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { parse as parseYaml } from "yaml"; - -const ROOT = join(import.meta.dir, "..", ".."); - -interface Step { - name?: string; - id?: string; - uses?: string; - if?: string; - run?: string; - env?: Record; - with?: Record; -} -interface Input { - required?: boolean; - type?: string; - default?: unknown; -} -interface Trigger { - inputs?: Record; -} +import { readWorkflow, SETUP_USES, type Workflow } from "./workflow-loader.js"; function must(value: T | undefined, what: string): T { if (value === undefined) throw new Error(`post-green.yml has no ${what}`); return value; } -interface CallerJob { - uses?: string; - with?: Record; - secrets?: unknown; - permissions?: Record; - steps?: Step[]; - [key: string]: unknown; -} - interface PinnedStep { name: string | undefined; id: string | undefined; @@ -51,14 +22,6 @@ interface PinnedStep { env: Record | undefined; with: Record | undefined; } -interface CallTrigger extends Trigger { - secrets?: Record; -} -interface Caller { - on: Record & { workflow_call?: CallTrigger | null }; - jobs: Record; - [key: string]: unknown; -} interface CallerContract { /** The workflow's top-level keys: no lane, permissions, or env above the jobs. */ @@ -222,18 +185,18 @@ const CALLER_EXPECTED: CallerContract = { { name: undefined, id: undefined, - uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + uses: SETUP_USES, if: PROCEED, run: undefined, env: undefined, - with: { "bun-version-file": ".bun-version" }, + with: undefined, }, { name: "Build the bundle and the library", id: undefined, uses: undefined, if: PROCEED, - run: "bun install --frozen-lockfile --ignore-scripts\nbun run build:bundle\nbun run build:lib\n", + run: "bun run build:bundle\nbun run build:lib\n", env: undefined, with: undefined, }, @@ -280,11 +243,11 @@ const CALLER_EXPECTED: CallerContract = { { name: undefined, id: undefined, - uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + uses: SETUP_USES, if: OIDC_PROCEED, run: undefined, env: undefined, - with: { "bun-version-file": ".bun-version" }, + with: undefined, }, { name: undefined, @@ -309,7 +272,7 @@ const CALLER_EXPECTED: CallerContract = { id: undefined, uses: undefined, if: OIDC_PROCEED, - run: "bun install --frozen-lockfile --ignore-scripts\nbun run build:lib\n", + run: "bun run build:lib", env: undefined, with: undefined, }, @@ -336,7 +299,7 @@ const CALLER_EXPECTED: CallerContract = { ], }; -function callerContractOf(wf: Caller): CallerContract { +function callerContractOf(wf: Workflow): CallerContract { const call = wf.on.workflow_call; return { topLevel: Object.keys(wf).sort(), @@ -368,20 +331,18 @@ function callerContractOf(wf: Caller): CallerContract { }; } -function expectCallerContract(wf: Caller): void { +function expectCallerContract(wf: Workflow): void { expect(callerContractOf(wf)).toEqual(CALLER_EXPECTED); } describe("post-green.yml publishes the build branch", () => { - const wf = parseYaml( - readFileSync(join(ROOT, ".github", "workflows", "post-green.yml"), "utf8"), - ) as Caller; + const wf = readWorkflow("post-green.yml"); test("two self-contained jobs, each gated on its probe, with the judged sha as the only input", () => { expectCallerContract(wf); }); - const REGRESSIONS: Array<[string, (w: Caller) => void, keyof CallerContract]> = [ + const REGRESSIONS: Array<[string, (w: Workflow) => void, keyof CallerContract]> = [ [ "an undeclared job beside the publisher", (w) => (w.jobs.extra = { "runs-on": "ubuntu-latest", steps: [{ run: "echo" }] }), @@ -614,11 +575,7 @@ describe("post-green.yml publishes the build branch", () => { }), "secrets", ], - [ - "a push trigger of its own", - (w) => (w.on.push = { branches: ["main"] } as Trigger), - "triggers", - ], + ["a push trigger of its own", (w) => (w.on.push = { branches: ["main"] }), "triggers"], [ "a dispatch that could reach the publisher outside the gate", (w) => (w.on.workflow_dispatch = null), @@ -694,9 +651,7 @@ const STATIC_ERROR = const FENCE_OPEN = /^::stop-commands::([0-9a-f]{32})$/; describe("the push probe under bash", () => { - const wf = parseYaml( - readFileSync(join(ROOT, ".github", "workflows", "post-green.yml"), "utf8"), - ) as Caller; + const wf = readWorkflow("post-green.yml"); const run = must(must(must(wf.jobs.build, "build job").steps?.[1], "probe step").run, "run"); function expectFenced(lines: string[], inner: string[]): void { diff --git a/test/docs/repo-owned-workflows.test.ts b/test/docs/repo-owned-workflows.test.ts new file mode 100644 index 00000000..b13bc7de --- /dev/null +++ b/test/docs/repo-owned-workflows.test.ts @@ -0,0 +1,716 @@ +/** + * Every job of a repo-owned workflow goes through the setup composite (or runs no bun at all) and carries a timeout; + * every third-party action is one sha with its version comment across the repo-owned files; lint:yaml runs for real + * where the composite hands the job yamllint. The managed workflows (ci.yml and the fleet's) are the platform's and + * stay out of every pin here. + */ + +import { describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { isAlias, isMap, isScalar, LineCounter, parseDocument, visit } from "yaml"; +import { + type CompositeAction, + installs, + isManaged, + type Job, + ROOT, + readAction, + readWorkflow, + repoOwnedWorkflowFiles, + SETUP_USES, + type Step, + setupInstalls, + setupYamllint, + workflowFiles, + workflowText, +} from "./workflow-loader.js"; + +const SETUP_DIR = ".github/actions/setup"; +const ACTIONS_DIR = ".github/actions"; +const YAMLLINT_VERSION = "1.38.0"; +/** The composite's header states the rule: 15 minutes unless the job's comment says why it needs more. */ +const DEFAULT_TIMEOUT = 15; + +const PACKAGE_SCRIPTS = ( + JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { + scripts: Record; + } +).scripts; + +/** + * A job's standing to the composite, or the defect found instead. + * install -> "composite" (the composite's), "own" (a run step's: float-canary re-resolves from scratch), "none" + * "no bun" -> a job no step of which runs bun (a git-only push, a gh-only reporter): nothing to set up + */ +type Setup = { install: string; yamllint: boolean; if: string | undefined } | string; +interface JobPin { + timeout: number | undefined; + setup: Setup; +} + +const composite = (over: Partial> = {}): Setup => ({ + install: "composite", + yamllint: false, + if: undefined, + ...over, +}); + +const EXPECTED: Record> = { + "auto-fix.yml": { + build: { timeout: 15, setup: composite() }, + // Downloads the patch and pushes with git alone: no install where the write token is. + push: { timeout: 10, setup: "no bun" }, + }, + "auto-format.yml": { + format: { timeout: 15, setup: composite() }, + // Applies the format job's patch and pushes; no PR code runs where the write token is. + push: { timeout: 10, setup: "no bun" }, + }, + "checks.yml": { + check: { timeout: 15, setup: composite({ yamllint: true }) }, + "boundary-check": { timeout: 5, setup: composite({ install: "none" }) }, + "anchor-check": { timeout: 5, setup: composite({ install: "none" }) }, + "schema-check": { timeout: 15, setup: composite() }, + "package-smoke": { timeout: 15, setup: composite() }, + "self-check": { timeout: 15, setup: composite() }, + "e2e-smoke": { timeout: 15, setup: composite() }, + "endpoint-coverage": { timeout: 15, setup: composite() }, + }, + "copilot-setup-steps.yml": { "copilot-setup-steps": { timeout: 59, setup: composite() } }, + "e2e-nightly.yml": { nightly: { timeout: 15, setup: composite() } }, + "nightly-fuzz.yml": { fuzz: { timeout: 60, setup: composite() } }, + "nightly.yml": { + checks: { timeout: 15, setup: composite() }, + "float-canary": { timeout: 15, setup: composite({ install: "own", yamllint: true }) }, + // A few gh calls and the fleet's issue action; no checkout, no bun. + report: { timeout: 5, setup: "no bun" }, + }, + "post-green.yml": { + build: { timeout: 10, setup: composite({ if: "steps.token.outputs.proceed == 'true'" }) }, + "publish-next": { + timeout: 10, + setup: composite({ if: "steps.oidc.outputs.proceed == 'true'" }), + }, + }, + "update-release-pr.yml": { anchor: { timeout: 5, setup: composite({ install: "none" }) } }, + "update-release.yml": { + "package-release": { timeout: 15, setup: composite() }, + "verify-release": { timeout: 5, setup: composite({ install: "none" }) }, + "publish-npm": { timeout: 10, setup: composite() }, + }, +}; + +/** The jobs above the default, each with its reason in the workflow beside the value. */ +const LONGER: Record = { + // Copilot's documented ceiling for this job. + "copilot-setup-steps.yml#copilot-setup-steps": 59, + // A 50-minute fuzz run bounded below it, so a hang fails instead of cancelling. + "nightly-fuzz.yml#fuzz": 60, +}; + +const MANAGED_FILES = ["auto-assign.yml", "ci.yml", "pr-title.yml"]; +const SETUP_INPUTS = ["install", "yamllint"]; + +/** A bun invocation the shell would run: the word `bun` at a command position. */ +const RUNS_BUN = /(?:^|[\s;&|(])bun(?=\s|$)/m; +const isSetupBun = (step: Step) => (step.uses ?? "").startsWith("oven-sh/setup-bun@"); +const isSetup = (step: Step) => step.uses === SETUP_USES; +/** A step that needs bun on the runner: a run step invoking it, or a local composite (each of ours runs bun). */ +const needsBun = (step: Step) => + RUNS_BUN.test(step.run ?? "") || (!isSetup(step) && (step.uses ?? "").startsWith("./")); + +function setupOf(job: Job): Setup { + const steps = job.steps ?? []; + const setups = steps.filter(isSetup); + const own = steps.some((step) => installs(step.run)); + if (setups.length !== 1) { + return setups.length === 0 && !steps.some(needsBun) && !steps.some(isSetupBun) + ? "no bun" + : `${setups.length} setup steps for a job running bun`; + } + if (steps.some(isSetupBun)) { + return "oven-sh/setup-bun outside the composite"; + } + const setup = setups[0] as Step; + const early = steps.slice(0, steps.indexOf(setup)).filter(needsBun); + if (early.length > 0) { + return `bun runs before the setup composite: ${early.map((step) => step.name ?? step.uses ?? step.run).join("; ")}`; + } + const unknown = Object.keys(setup.with ?? {}).filter((key) => !SETUP_INPUTS.includes(key)); + if (unknown.length > 0) { + return `unknown setup inputs: ${unknown.join(", ")}`; + } + // An expression's value is the runner's to compute; the pin can only read a literal. + const expressions = SETUP_INPUTS.filter((key) => String(setup.with?.[key] ?? "").includes("${{")); + if (expressions.length > 0) { + return `setup inputs given as expressions, not literals: ${expressions.join(", ")}`; + } + const install = + [setupInstalls(setup) ? "composite" : "", own ? "own" : ""].filter(Boolean).join(" and ") || + "none"; + return { install, yamllint: setupYamllint(setup), if: setup.if }; +} + +function pinOf(job: Job): JobPin { + return { timeout: job["timeout-minutes"], setup: setupOf(job) }; +} + +function pinsOf(file: string): Record { + return Object.fromEntries( + Object.entries(readWorkflow(file).jobs).map(([id, job]) => [id, pinOf(job)]), + ); +} + +describe("the repo-owned workflows", () => { + test("the files split into exactly the pinned repo-owned and managed sets", () => { + expect(repoOwnedWorkflowFiles()).toEqual(Object.keys(EXPECTED).sort()); + expect(workflowFiles().filter((file) => isManaged(workflowText(file)))).toEqual(MANAGED_FILES); + }); + + test("every job goes through the setup composite once (or runs no bun) and carries its pinned timeout", () => { + expect( + Object.fromEntries(repoOwnedWorkflowFiles().map((file) => [file, pinsOf(file)])), + ).toEqual(EXPECTED); + }); + + test(`timeouts above ${DEFAULT_TIMEOUT} minutes are exactly the jobs whose comment says why`, () => { + const pins = Object.entries(EXPECTED).flatMap(([file, jobs]) => + Object.entries(jobs).map(([id, pin]) => [`${file}#${id}`, pin] as const), + ); + for (const [where, pin] of pins) { + expect(pin.timeout, `${where} has no timeout`).toBeGreaterThan(0); + } + expect( + Object.fromEntries( + pins + .filter(([, pin]) => (pin.timeout ?? 0) > DEFAULT_TIMEOUT) + .map(([where, pin]) => [where, pin.timeout]), + ), + ).toEqual(LONGER); + }); + + const check = () => structuredClone(readWorkflow("checks.yml").jobs.check as Job); + const setupStep = (job: Job) => (job.steps ?? []).find(isSetup) as Step; + + test.each<[string, (job: Job) => void, JobPin]>([ + [ + "a dropped timeout", + (job) => delete job["timeout-minutes"], + { timeout: undefined, setup: composite({ yamllint: true }) }, + ], + [ + "a job running bun without the composite", + (job) => (job.steps = job.steps?.filter((step) => !isSetup(step))), + { timeout: 15, setup: "0 setup steps for a job running bun" }, + ], + [ + "a duplicated composite", + (job) => job.steps?.push({ uses: SETUP_USES }), + { timeout: 15, setup: "2 setup steps for a job running bun" }, + ], + [ + "the composite moved after the steps that need it", + (job) => { + const steps = job.steps ?? []; + const [setup] = steps.splice(steps.findIndex(isSetup), 1); + steps.push(setup as Step); + }, + { + timeout: 15, + setup: + "bun runs before the setup composite: ./.github/actions/fetch-test-artifacts; Lint (biome); Lint (yaml); " + + "Lint (architecture); Typecheck; Dead code (knip); Compat markers; Test", + }, + ], + [ + "one bun step ahead of the composite", + (job) => job.steps?.splice(1, 0, { run: "bun --version" }), + { timeout: 15, setup: "bun runs before the setup composite: bun --version" }, + ], + [ + "setup-bun beside the composite", + (job) => + job.steps?.push({ uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6" }), + { timeout: 15, setup: "oven-sh/setup-bun outside the composite" }, + ], + [ + "setup-bun instead of the composite", + (job) => + (job.steps = job.steps?.map((step) => + isSetup(step) + ? { uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6" } + : step, + )), + { timeout: 15, setup: "0 setup steps for a job running bun" }, + ], + [ + "an inline install beside the composite's", + (job) => job.steps?.push({ run: "bun install --frozen-lockfile" }), + { timeout: 15, setup: composite({ install: "composite and own", yamllint: true }) }, + ], + [ + "a misspelled input", + (job) => (setupStep(job).with = { yamlint: "true" }), + { timeout: 15, setup: "unknown setup inputs: yamlint" }, + ], + [ + "an input given as an expression the runner would evaluate to false", + (job) => (setupStep(job).with = { install: `\${{ 'false' }}`, yamllint: "true" }), + { timeout: 15, setup: "setup inputs given as expressions, not literals: install" }, + ], + [ + "yamllint switched off", + (job) => (setupStep(job).with = { yamllint: "false" }), + { timeout: 15, setup: composite() }, + ], + [ + "the install switched off", + (job) => (setupStep(job).with = { install: "false", yamllint: "true" }), + { timeout: 15, setup: composite({ install: "none", yamllint: true }) }, + ], + // GitHub compares expression strings without regard to letter case, so these spellings switch at runtime too. + [ + "the install switched off in capitals", + (job) => (setupStep(job).with = { install: "FALSE", yamllint: "true" }), + { timeout: 15, setup: composite({ install: "none", yamllint: true }) }, + ], + [ + "yamllint switched off in capitals", + (job) => (setupStep(job).with = { yamllint: "False" }), + { timeout: 15, setup: composite() }, + ], + [ + "a gated setup", + (job) => (setupStep(job).if = "github.event_name == 'push'"), + { timeout: 15, setup: composite({ yamllint: true, if: "github.event_name == 'push'" }) }, + ], + ])("%s derives away from the pin (negative control)", (_, mutate, derived) => { + const job = check(); + mutate(job); + expect(pinOf(job)).toEqual(derived); + expect(pinOf(job)).not.toEqual(EXPECTED["checks.yml"]?.check as JobPin); + }); + + test("a job with no bun anywhere derives to no bun, and one bun call derives away (negative control)", () => { + const job: Job = { + "timeout-minutes": 5, + steps: [{ run: "gh pr list" }, { uses: "actions/checkout@0" }], + }; + expect(setupOf(job)).toBe("no bun"); + job.steps?.push({ run: "gh pr list\nbun .github/scripts/release-pipeline.ts verify" }); + expect(setupOf(job)).toBe("0 setup steps for a job running bun"); + }); + + test("a local composite (which runs bun) without the setup derives away from no bun (negative control)", () => { + const job: Job = { + "timeout-minutes": 5, + steps: [{ uses: "actions/checkout@0" }, { uses: "./.github/actions/fetch-test-artifacts" }], + }; + expect(setupOf(job)).toBe("0 setup steps for a job running bun"); + }); + + test.each([ + ["a bare command", true, "bun test"], + ["a command inside a script", true, "echo start\nbun run check"], + ["a command after a separator", true, "cd lib && bun install"], + ["a command inside a subshell", true, "out=$(bun --version)"], + ["the lockfile name", false, "rm bun.lock"], + ["a word containing it", false, "echo ubuntu bundle"], + ["a comment naming it", true, "# bun runs here"], + ])("RUNS_BUN: %s -> %p", (_, expected, run) => { + expect(RUNS_BUN.test(run)).toBe(expected); + }); +}); + +/** One `uses:` value and the comment on its line (the text after `#`, trimmed): the version pin lives in that comment. */ +interface UsesLine { + where: string; + uses: string; + comment: string; +} + +function actionDirs(): string[] { + return readdirSync(join(ROOT, ACTIONS_DIR)) + .map((name) => `${ACTIONS_DIR}/${name}`) + .sort(); +} + +/** Every `uses:` in the YAML syntax tree, block or flow style, an alias resolved, with the comment on its line. */ +function usesLinesIn(where: string, text: string): UsesLine[] { + const lineCounter = new LineCounter(); + const doc = parseDocument(text, { lineCounter }); + const lines: UsesLine[] = []; + visit(doc, { + Pair(_, pair, path) { + const written = pair.value; + const key = isAlias(pair.key) ? pair.key.resolve(doc) : pair.key; + if (!isScalar(key) || key.value !== "uses") { + return; + } + if (!(isScalar(written) || isAlias(written))) { + return; + } + const node = isAlias(written) ? written.resolve(doc) : written; + if (!isScalar(node)) { + return; + } + // A flow step's comment (`- {uses: x} # v1`) sits on the enclosing map, not on the scalar. + const parent = path[path.length - 1]; + const comment = + written.comment ?? (isMap(parent) && parent.flow ? parent.comment : undefined) ?? ""; + lines.push({ + where: `${where}:${lineCounter.linePos(written.range?.[0] ?? 0).line}`, + uses: String(node.value), + comment: comment.trim(), + }); + }, + }); + return lines; +} + +function usesLines(): UsesLine[] { + return [ + ...repoOwnedWorkflowFiles().flatMap((file) => + usesLinesIn(`.github/workflows/${file}`, workflowText(file)), + ), + ...actionDirs().flatMap((dir) => + usesLinesIn(`${dir}/action.yml`, readFileSync(join(ROOT, dir, "action.yml"), "utf8")), + ), + ]; +} + +const SHA_PIN = /^[\w.-]+\/[\w.-]+(?:\/[\w./-]+)?@[0-9a-f]{40}$/; +const VERSION_COMMENT = /^v\d+(?:\.\d+)*$/; +/** The fleet's own actions ride their stable channel by design; any other ref is a pin problem. */ +const FLEET_STABLE = /^Vivswan\/repo-platform\/actions\/[\w-]+@stable$/; + +/** Why a `uses:` fails the pin policy, or undefined when it passes. */ +function pinProblem({ uses, comment }: Pick): string | undefined { + if (uses.startsWith("./")) { + return comment === "" ? undefined : "a local action carries no version comment"; + } + if (FLEET_STABLE.test(uses)) { + return undefined; + } + if (!SHA_PIN.test(uses)) { + return "not a full-sha pin"; + } + if (!VERSION_COMMENT.test(comment)) { + return "no version comment after the sha"; + } + return undefined; +} + +describe("action pins across the repo-owned files", () => { + test("every uses: is a local path, the fleet's action on its branch, or a full sha with its version comment", () => { + const lines = usesLines(); + // Control: the scan reads real lines (a regex drift would pass vacuously). + expect(lines.length).toBeGreaterThan(30); + expect(lines.some(({ uses }) => uses === SETUP_USES)).toBe(true); + expect( + lines.flatMap((line) => { + const problem = pinProblem(line); + return problem ? [`${line.where}: ${line.uses} (${problem})`] : []; + }), + ).toEqual([]); + }); + + test("the scan reads block, flow, and aliased steps alike, with the comment and line of each (control)", () => { + const text = [ + "steps:", + ` - uses: actions/checkout@${"a".repeat(40)} # v7.0.1`, + " with: {ref: main}", + " - {uses: actions/checkout@v7}", + ` - {uses: actions/checkout@${"b".repeat(40)}, with: {ref: main}} # v7.0.2`, + " - name: &action actions/checkout@v8", + " uses: *action # v8.0.0", + " - name: &key uses", + " *key : actions/checkout@v9", + " # uses: commented/out@v1", + " - uses: ./.github/actions/setup", + "", + ].join("\n"); + expect(usesLinesIn("x.yml", text)).toEqual([ + { where: "x.yml:2", uses: `actions/checkout@${"a".repeat(40)}`, comment: "v7.0.1" }, + { where: "x.yml:4", uses: "actions/checkout@v7", comment: "" }, + { where: "x.yml:5", uses: `actions/checkout@${"b".repeat(40)}`, comment: "v7.0.2" }, + { where: "x.yml:7", uses: "actions/checkout@v8", comment: "v8.0.0" }, + { where: "x.yml:9", uses: "actions/checkout@v9", comment: "" }, + { where: "x.yml:11", uses: "./.github/actions/setup", comment: "" }, + ]); + }); + + test.each([ + ["a tag ref", "actions/checkout@v7", "", "not a full-sha pin"], + ["a short sha", "actions/checkout@3d3c42e", "v7.0.1", "not a full-sha pin"], + [ + "a sha without its comment", + `actions/checkout@${"a".repeat(40)}`, + "", + "no version comment after the sha", + ], + [ + "a sha with a prose comment", + `actions/checkout@${"a".repeat(40)}`, + "pinned", + "no version comment after the sha", + ], + [ + "the fleet's action at a tag", + "Vivswan/repo-platform/actions/fuzz-issue@v1", + "", + "not a full-sha pin", + ], + [ + "the fleet's action on its stable channel", + "Vivswan/repo-platform/actions/fuzz-issue@stable", + "", + undefined, + ], + [ + "the fleet's action on the branch it left", + "Vivswan/repo-platform/actions/fuzz-issue@build", + "", + "not a full-sha pin", + ], + [ + "the fleet's action at a sha", + `Vivswan/repo-platform/actions/fuzz-issue@${"b".repeat(40)}`, + "v1.2.0", + undefined, + ], + ["a local action", "./.github/actions/setup", "", undefined], + [ + "a local action with a comment", + "./.github/actions/setup", + "v1", + "a local action carries no version comment", + ], + ["the action root", "./", "", undefined], + ])("pinProblem(): %s", (_, uses, comment, problem) => { + expect(pinProblem({ uses, comment })).toBe(problem); + }); + + test("each third-party action is one sha and one version everywhere it appears", () => { + const byAction = new Map>(); + for (const { uses, comment } of usesLines()) { + if (!SHA_PIN.test(uses)) { + continue; + } + const [action, sha] = uses.split("@") as [string, string]; + byAction.set(action, (byAction.get(action) ?? new Set()).add(`${sha} ${comment}`)); + } + expect([...byAction.keys()].sort()).toEqual([ + "actions/cache", + "actions/checkout", + "actions/download-artifact", + "actions/setup-node", + "actions/upload-artifact", + "marocchino/sticky-pull-request-comment", + "oven-sh/setup-bun", + ]); + for (const [action, pins] of byAction) { + expect([...pins], `${action} is pinned ${pins.size} ways`).toHaveLength(1); + } + }); +}); + +describe("the setup composite", () => { + const action = readAction(SETUP_DIR); + + test("declares the two inputs with their defaults and runs bun, the gated install, and the gated yamllint", () => { + expect(action.runs.using).toBe("composite"); + expect(action.inputs).toEqual({ + install: { description: expect.any(String), default: "true" }, + yamllint: { description: expect.any(String), default: "false" }, + }); + expect( + (action.runs.steps ?? []).map(({ uses, with: inputs, if: gate, shell, run }) => ({ + uses, + with: inputs, + if: gate, + shell, + run, + })), + ).toEqual([ + { + uses: expect.stringMatching(/^oven-sh\/setup-bun@[0-9a-f]{40}$/), + with: { "bun-version-file": ".bun-version" }, + if: undefined, + shell: undefined, + run: undefined, + }, + { + uses: undefined, + with: undefined, + if: "inputs.install != 'false'", + shell: "bash", + run: "bun install --frozen-lockfile --ignore-scripts", + }, + { + uses: undefined, + with: undefined, + if: "inputs.yamllint == 'true'", + shell: "bash", + run: `pipx install yamllint==${YAMLLINT_VERSION}`, + }, + ]); + }); + + test("every run step of every composite names its shell (the runner rejects one without at job start)", () => { + for (const dir of actionDirs()) { + for (const step of readAction(dir).runs.steps ?? []) { + if (step.run !== undefined) { + expect(step.shell, `${dir}: step ${step.name ?? step.id ?? step.run}`).toBe("bash"); + } + } + } + }); + + /** The composites (by directory) whose steps run oven-sh/setup-bun: a second one would replace the pinned bun. */ + const setupBunHosts = (actions: ReadonlyArray<[string, CompositeAction]>) => + actions.filter(([, action]) => (action.runs.steps ?? []).some(isSetupBun)).map(([dir]) => dir); + + test("oven-sh/setup-bun runs in the setup composite and nowhere else", () => { + expect(setupBunHosts(actionDirs().map((dir) => [dir, readAction(dir)]))).toEqual([SETUP_DIR]); + }); + + test("a setup-bun added to another composite is reported by directory (negative control)", () => { + const fetch = structuredClone(readAction(`${ACTIONS_DIR}/fetch-test-artifacts`)); + fetch.runs.steps?.unshift({ + uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + with: { "bun-version": "latest" }, + }); + expect( + setupBunHosts([ + [`${ACTIONS_DIR}/fetch-test-artifacts`, fetch], + [SETUP_DIR, action], + ]), + ).toEqual([`${ACTIONS_DIR}/fetch-test-artifacts`, SETUP_DIR]); + }); +}); + +/** A job's run steps name lint:yaml, directly or through `bun run check`. */ +const RUNS_LINT_YAML = /\bbun run (?:check|lint:yaml)(?![\w:.-])/; + +/** `#` for every repo-owned job with a step matching `test`. */ +function jobsWhere(test: (step: Step) => boolean): string[] { + return repoOwnedWorkflowFiles() + .flatMap((file) => + Object.entries(readWorkflow(file).jobs) + .filter(([, job]) => (job.steps ?? []).some(test)) + .map(([id]) => `${file}#${id}`), + ) + .sort(); +} + +interface LintYamlRun { + status: number; + stdout: string; + stderr: string; + /** The arguments the yamllint stub saw, one per line; undefined when it was never called. */ + calls: string[] | undefined; +} + +/** + * The lint:yaml script under bash from the repository root, PATH cut to the system directories plus a stub bin: with + * the stub, yamllint records its arguments; without it, `command -v yamllint` fails. The scratch directory is + * removed on every path. + */ +function runLintYaml(env: Record, stub: boolean): LintYamlRun { + const dir = mkdtempSync(join(tmpdir(), "lint-yaml-")); + try { + const bin = join(dir, "bin"); + mkdirSync(bin); + const calls = join(dir, "calls"); + if (stub) { + writeFileSync(join(bin, "yamllint"), `#!/bin/sh\nprintf '%s\\n' "$@" > "${calls}"\n`, { + mode: 0o755, + }); + } + let status = 0; + let stdout = ""; + let stderr = ""; + try { + stdout = execFileSync("bash", ["-c", PACKAGE_SCRIPTS["lint:yaml"] ?? ""], { + cwd: ROOT, + encoding: "utf8", + env: { HOME: process.env.HOME ?? "", ...env, PATH: `${bin}:/usr/bin:/bin` }, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + status = (error as { status?: number }).status ?? -1; + stdout = String((error as { stdout?: string }).stdout ?? ""); + stderr = String((error as { stderr?: string }).stderr ?? ""); + } + return { + status, + stdout, + stderr, + calls: existsSync(calls) + ? readFileSync(calls, "utf8").split("\n").filter(Boolean) + : undefined, + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("lint:yaml", () => { + test("the composite hands yamllint to exactly the jobs that run it", () => { + const running = jobsWhere((step) => RUNS_LINT_YAML.test(step.run ?? "")); + expect(running).toEqual(["checks.yml#check", "nightly.yml#float-canary"]); + expect(jobsWhere(setupYamllint)).toEqual(running); + // The check script is what float-canary runs; it must carry lint:yaml for the derivation above to mean anything. + expect(PACKAGE_SCRIPTS.check).toContain("bun run lint:yaml"); + }); + + test.each([ + ["bun run check", true], + ["bun run lint:yaml", true], + ["bun run check:compat", false], + ["bun run lint", false], + ["bun run build:check", false], + ])("RUNS_LINT_YAML: %j -> %p", (run, expected) => { + expect(RUNS_LINT_YAML.test(run)).toBe(expected); + }); + + test("with yamllint on PATH it lints every tracked yaml file in strict mode", () => { + const run = runLintYaml({}, true); + expect(run.status).toBe(0); + expect(run.calls?.[0]).toBe("-s"); + expect(run.calls).toContain(".github/workflows/checks.yml"); + expect(run.calls).toContain("architecture.yml"); + expect(run.calls?.some((arg) => arg.endsWith(".json"))).toBe(false); + }); + + test("without yamllint a CI run fails naming the composite input (control: the skip is local-only)", () => { + const run = runLintYaml({ CI: "true" }, false); + expect(run.status).toBe(1); + expect(run.calls).toBeUndefined(); + expect(run.stdout).toBe(""); + expect(run.stderr.trim()).toBe( + 'lint:yaml: yamllint is missing on this runner; the job needs ./.github/actions/setup with yamllint: "true"', + ); + }); + + test("without yamllint a local run says it skipped and passes", () => { + const run = runLintYaml({}, false); + expect(run.status).toBe(0); + expect(run.calls).toBeUndefined(); + expect(run.stdout.trim()).toBe( + "lint:yaml: yamllint not installed, skipping (CI runs it; install with pip install yamllint or brew install yamllint)", + ); + }); +}); diff --git a/test/docs/workflow-loader.ts b/test/docs/workflow-loader.ts new file mode 100644 index 00000000..63f0a907 --- /dev/null +++ b/test/docs/workflow-loader.ts @@ -0,0 +1,163 @@ +/** + * One reader for the workflow and composite-action YAML the docs tests pin: the file list split by header, the parsed + * shapes, and the step predicates around the setup composite every repo-owned job goes through. + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; + +export const ROOT = join(import.meta.dir, "..", ".."); +const WORKFLOWS_DIR = join(ROOT, ".github", "workflows"); +/** The setup composite (bun, the locked install, yamllint on request), as a job's `uses:` spells it. */ +export const SETUP_USES = "./.github/actions/setup"; +/** The header line of a file the platform sync overwrites; every other workflow is repo-owned. */ +const MANAGED_HEADER = "managed by Vivswan/repo-platform"; + +export interface Step { + name?: string; + id?: string; + uses?: string; + run?: string; + shell?: string; + if?: string; + "continue-on-error"?: boolean; + env?: Record; + with?: Record; +} +interface Concurrency { + group?: string; + queue?: string; + "cancel-in-progress"?: boolean | string; +} +/** A job: steps on a runner, or (`uses`) a call of a reusable workflow. */ +export interface Job { + if?: string; + needs?: string | string[]; + "runs-on"?: string; + "timeout-minutes"?: number; + permissions?: Record; + concurrency?: Concurrency; + strategy?: unknown; + outputs?: Record; + steps?: Step[]; + uses?: string; + with?: Record; + secrets?: unknown; +} +interface TriggerInput { + description?: string; + required?: boolean; + type?: string; + default?: unknown; +} +interface Trigger { + inputs?: Record; + secrets?: Record; + [key: string]: unknown; +} +export interface Workflow { + name?: string; + on: Record; + permissions?: Record; + concurrency?: Concurrency; + jobs: Record; +} +interface ActionInput { + description?: string; + required?: boolean; + default?: string; +} +export interface CompositeAction { + name?: string; + description?: string; + inputs?: Record; + runs: { using?: string; steps?: Step[] }; +} + +export function workflowText(file: string): string { + return readFileSync(join(WORKFLOWS_DIR, file), "utf8"); +} + +export function readWorkflow(file: string): Workflow { + return parseYaml(workflowText(file)) as Workflow; +} + +/** `dir` is relative to the repository root, e.g. `.github/actions/setup`. */ +export function readAction(dir: string): CompositeAction { + return parseYaml(readFileSync(join(ROOT, dir, "action.yml"), "utf8")) as CompositeAction; +} + +/** Every workflow file, sorted. */ +export function workflowFiles(): string[] { + return readdirSync(WORKFLOWS_DIR) + .filter((file) => /\.ya?ml$/.test(file)) + .sort(); +} + +/** True when the file's leading comment block carries the managed-by header. */ +export function isManaged(text: string): boolean { + for (const line of text.split("\n")) { + if (line.trim() === "") { + continue; + } + if (!line.startsWith("#")) { + return false; + } + if (line.includes(MANAGED_HEADER)) { + return true; + } + } + return false; +} + +export function repoOwnedWorkflowFiles(): string[] { + return workflowFiles().filter((file) => !isManaged(workflowText(file))); +} + +/** The run scalar's lines with every heredoc body (`< /^\s*bun install(?:\s|$)/.test(line) && !line.includes("||"), + ); +} + +/** + * A setup input as the runner compares it: lower-cased, since GitHub compares expression strings without regard to + * letter case. An expression (`${{ ... }}`) has no value here, so it is undefined and satisfies neither predicate. + */ +function setupInput(step: Step, name: string, fallback: string): string | undefined { + const raw = String(step.with?.[name] ?? fallback); + return raw.includes("${{") ? undefined : raw.toLowerCase(); +} + +/** The setup composite step when it installs: any literal `install` input but "false" does. */ +export function setupInstalls(step: Step): boolean { + const install = setupInput(step, "install", "true"); + return step.uses === SETUP_USES && install !== undefined && install !== "false"; +} + +/** The setup composite step when it installs yamllint. */ +export function setupYamllint(step: Step): boolean { + return step.uses === SETUP_USES && setupInput(step, "yamllint", "false") === "true"; +} From 45c5acebfecd9f2bce8360241c39c4308b2148c1 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:57:18 -0400 Subject: [PATCH 2/9] test(docs): pin continue-on-error on the setup composite's steps --- test/docs/repo-owned-workflows.test.ts | 99 ++++++++++++++++++-------- 1 file changed, 68 insertions(+), 31 deletions(-) diff --git a/test/docs/repo-owned-workflows.test.ts b/test/docs/repo-owned-workflows.test.ts index b13bc7de..15d87e70 100644 --- a/test/docs/repo-owned-workflows.test.ts +++ b/test/docs/repo-owned-workflows.test.ts @@ -526,6 +526,55 @@ describe("action pins across the repo-owned files", () => { }); }); +/** A composite step as the pin reads it: every field the runner acts on, so a masked failure or a new gate shows. */ +interface StepShape { + uses: string | undefined; + with: Record | undefined; + if: string | undefined; + shell: string | undefined; + run: string | undefined; + "continue-on-error": boolean | undefined; +} + +function shapeOf(steps: Step[]): StepShape[] { + return steps.map((step) => ({ + uses: step.uses, + with: step.with, + if: step.if, + shell: step.shell, + run: step.run, + "continue-on-error": step["continue-on-error"], + })); +} + +/** The composite's three steps: none may mask its failure, since a job relies on each one it asks for. */ +const SETUP_SHAPE: StepShape[] = [ + { + uses: expect.stringMatching(/^oven-sh\/setup-bun@[0-9a-f]{40}$/) as unknown as string, + with: { "bun-version-file": ".bun-version" }, + if: undefined, + shell: undefined, + run: undefined, + "continue-on-error": undefined, + }, + { + uses: undefined, + with: undefined, + if: "inputs.install != 'false'", + shell: "bash", + run: "bun install --frozen-lockfile --ignore-scripts", + "continue-on-error": undefined, + }, + { + uses: undefined, + with: undefined, + if: "inputs.yamllint == 'true'", + shell: "bash", + run: `pipx install yamllint==${YAMLLINT_VERSION}`, + "continue-on-error": undefined, + }, +]; + describe("the setup composite", () => { const action = readAction(SETUP_DIR); @@ -535,37 +584,25 @@ describe("the setup composite", () => { install: { description: expect.any(String), default: "true" }, yamllint: { description: expect.any(String), default: "false" }, }); - expect( - (action.runs.steps ?? []).map(({ uses, with: inputs, if: gate, shell, run }) => ({ - uses, - with: inputs, - if: gate, - shell, - run, - })), - ).toEqual([ - { - uses: expect.stringMatching(/^oven-sh\/setup-bun@[0-9a-f]{40}$/), - with: { "bun-version-file": ".bun-version" }, - if: undefined, - shell: undefined, - run: undefined, - }, - { - uses: undefined, - with: undefined, - if: "inputs.install != 'false'", - shell: "bash", - run: "bun install --frozen-lockfile --ignore-scripts", - }, - { - uses: undefined, - with: undefined, - if: "inputs.yamllint == 'true'", - shell: "bash", - run: `pipx install yamllint==${YAMLLINT_VERSION}`, - }, - ]); + expect(shapeOf(action.runs.steps ?? [])).toEqual(SETUP_SHAPE); + }); + + test.each<[string, (step: Step) => Step]>([ + ["an install allowed to fail", (step) => ({ ...step, "continue-on-error": true })], + ["an install with its lockfile unfrozen", (step) => ({ ...step, run: "bun install" })], + [ + "an install that runs lifecycle scripts", + (step) => ({ ...step, run: "bun install --frozen-lockfile" }), + ], + [ + "an install gated on a different spelling", + (step) => ({ ...step, if: "inputs.install == 'true'" }), + ], + ])("%s inside the composite fails the shape pin (negative control)", (_, mutate) => { + const steps = (action.runs.steps ?? []).map((step) => + step.run?.startsWith("bun install") ? mutate(step) : step, + ); + expect(() => expect(shapeOf(steps)).toEqual(SETUP_SHAPE)).toThrow(); }); test("every run step of every composite names its shell (the runner rejects one without at job start)", () => { From d3e5d8bc4a53323431bdac95d98f43cd9b887479 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:01:23 -0400 Subject: [PATCH 3/9] test(docs): narrow the shape projection's comment to what it reads --- test/docs/repo-owned-workflows.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/docs/repo-owned-workflows.test.ts b/test/docs/repo-owned-workflows.test.ts index 15d87e70..e5bddf02 100644 --- a/test/docs/repo-owned-workflows.test.ts +++ b/test/docs/repo-owned-workflows.test.ts @@ -526,7 +526,7 @@ describe("action pins across the repo-owned files", () => { }); }); -/** A composite step as the pin reads it: every field the runner acts on, so a masked failure or a new gate shows. */ +/** A composite step as the pin reads it: the fields that decide what runs and whether a failure counts. */ interface StepShape { uses: string | undefined; with: Record | undefined; @@ -550,7 +550,7 @@ function shapeOf(steps: Step[]): StepShape[] { /** The composite's three steps: none may mask its failure, since a job relies on each one it asks for. */ const SETUP_SHAPE: StepShape[] = [ { - uses: expect.stringMatching(/^oven-sh\/setup-bun@[0-9a-f]{40}$/) as unknown as string, + uses: expect.stringMatching(/^oven-sh\/setup-bun@[0-9a-f]{40}$/), with: { "bun-version-file": ".bun-version" }, if: undefined, shell: undefined, From a153f60cf5c3e57ec3c5f493eae60f8d4af085f7 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:03:31 -0400 Subject: [PATCH 4/9] ci(auto-format): skip the push when the head moved since the format, and lease the push to that head --- .github/workflows/auto-format.yml | 14 ++++++- test/docs/repo-owned-workflows.test.ts | 51 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/.github/workflows/auto-format.yml b/.github/workflows/auto-format.yml index 09ab6fd7..76d56f83 100644 --- a/.github/workflows/auto-format.yml +++ b/.github/workflows/auto-format.yml @@ -30,6 +30,7 @@ jobs: timeout-minutes: 15 outputs: changed: ${{ steps.format.outputs.changed }} + head: ${{ steps.format.outputs.head }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -40,6 +41,8 @@ jobs: - name: Format (biome) and stage the patch id: format run: | + # The patch belongs to this commit alone; the push job refuses any other head. + echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" bun run lint:fix git add -A git diff --cached --binary > "$RUNNER_TEMP/format.patch" @@ -84,12 +87,21 @@ jobs: env: GH_TOKEN: ${{ github.token }} HEAD_REF: ${{ github.event.pull_request.head.ref }} + HEAD_SHA: ${{ needs.format.outputs.head }} run: | + # The branch can move between the two jobs; the patch was cut on HEAD_SHA and formats that tree alone, so a + # newer head gets its own run (re-apply the label) instead of a commit formatting the old one. + if [ "$(git rev-parse HEAD)" != "$HEAD_SHA" ]; then + echo "::notice::head moved since the format; skipping the stale formatting push" + exit 0 + fi git apply --index --binary "$RUNNER_TEMP/format/format.patch" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git commit --no-verify -m "style: apply automated formatting" - git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:"$HEAD_REF" + # The lease pins the remote to the commit the patch was cut on, so a push that lands in between rejects this one. + git push --force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}" \ + "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:"refs/heads/${HEAD_REF}" - name: Remove the fix-lint label if: always() env: diff --git a/test/docs/repo-owned-workflows.test.ts b/test/docs/repo-owned-workflows.test.ts index e5bddf02..c1e41b4e 100644 --- a/test/docs/repo-owned-workflows.test.ts +++ b/test/docs/repo-owned-workflows.test.ts @@ -641,6 +641,57 @@ describe("the setup composite", () => { /** A job's run steps name lint:yaml, directly or through `bun run check`. */ const RUNS_LINT_YAML = /\bbun run (?:check|lint:yaml)(?![\w:.-])/; +/** + * The commit-back push jobs: the patch was cut on one head, so a head that moved before the push skips with a notice, + * and the push itself is leased to that head. Both workflows spell the guard the same way. + */ +const HEAD_MOVED_GUARD = + /^if \[ "\$\(git rev-parse HEAD\)" != "\$HEAD_SHA" \]; then\n {2}echo "::notice::head moved .*"\n {2}exit 0\nfi$/m; +const LEASED_PUSH = /git push --force-with-lease="refs\/heads\/\$\{HEAD_REF\}:\$\{HEAD_SHA\}"/; +const PUSH_JOBS: ReadonlyArray<[string, string, string]> = [ + ["auto-fix.yml", "Commit and push the fix", `\${{ github.event.pull_request.head.sha }}`], + ["auto-format.yml", "Commit and push the formatting", `\${{ needs.format.outputs.head }}`], +]; + +describe("the commit-back push jobs", () => { + test.each(PUSH_JOBS)( + "%s: the push step skips on a moved head and leases the push to it", + (file, name, sha) => { + const step = readWorkflow(file).jobs.push?.steps?.find( + (candidate) => candidate.name === name, + ); + expect(step, `${file} has no step named ${name}`).toBeDefined(); + expect(step?.env?.HEAD_SHA).toBe(sha); + const run = step?.run ?? ""; + expect(run, `${file}: no head-moved guard`).toMatch(HEAD_MOVED_GUARD); + expect(run, `${file}: the push is not leased to HEAD_SHA`).toMatch(LEASED_PUSH); + // The guard runs before anything is applied or pushed. + expect(run.search(HEAD_MOVED_GUARD)).toBeLessThan(run.indexOf("git apply")); + }, + ); + + test.each([ + [ + "a guard that only warns", + 'if [ "$(git rev-parse HEAD)" != "$HEAD_SHA" ]; then\n echo "::notice::head moved"\nfi', + ], + [ + "a guard on the wrong variable", + 'if [ "$(git rev-parse HEAD)" != "$BASE_SHA" ]; then\n echo "::notice::head moved"\n exit 0\nfi', + ], + ["no guard", 'git apply --index --binary "$RUNNER_TEMP/format/format.patch"'], + ])("%s fails the guard pin (negative control)", (_, run) => { + expect(run).not.toMatch(HEAD_MOVED_GUARD); + }); + + test("the format job hands its head to the push job (control)", () => { + const format = readWorkflow("auto-format.yml").jobs.format; + expect(format?.outputs?.head).toBe(`\${{ steps.format.outputs.head }}`); + const step = format?.steps?.find((candidate) => candidate.id === "format"); + expect(step?.run).toContain('echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"'); + }); +}); + /** `#` for every repo-owned job with a step matching `test`. */ function jobsWhere(test: (step: Step) => boolean): string[] { return repoOwnedWorkflowFiles() From 95dbae84b76e369f9ac1b0416d906297f3b4c68a Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:09:10 -0400 Subject: [PATCH 5/9] test(docs): let the head-moved guard controls fail on their own clause --- test/docs/repo-owned-workflows.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/docs/repo-owned-workflows.test.ts b/test/docs/repo-owned-workflows.test.ts index c1e41b4e..79c35716 100644 --- a/test/docs/repo-owned-workflows.test.ts +++ b/test/docs/repo-owned-workflows.test.ts @@ -673,11 +673,11 @@ describe("the commit-back push jobs", () => { test.each([ [ "a guard that only warns", - 'if [ "$(git rev-parse HEAD)" != "$HEAD_SHA" ]; then\n echo "::notice::head moved"\nfi', + 'if [ "$(git rev-parse HEAD)" != "$HEAD_SHA" ]; then\n echo "::notice::head moved since the format"\nfi', ], [ "a guard on the wrong variable", - 'if [ "$(git rev-parse HEAD)" != "$BASE_SHA" ]; then\n echo "::notice::head moved"\n exit 0\nfi', + 'if [ "$(git rev-parse HEAD)" != "$BASE_SHA" ]; then\n echo "::notice::head moved since the format"\n exit 0\nfi', ], ["no guard", 'git apply --index --binary "$RUNNER_TEMP/format/format.patch"'], ])("%s fails the guard pin (negative control)", (_, run) => { From bad0d01b687b3661222287529c2d045cf4d29765 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:18:37 -0400 Subject: [PATCH 6/9] ci(auto-format): refuse a format patch that does more than modify tracked files --- .github/workflows/auto-format.yml | 23 +++++- test/docs/repo-owned-workflows.test.ts | 102 +++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/.github/workflows/auto-format.yml b/.github/workflows/auto-format.yml index 76d56f83..8698d436 100644 --- a/.github/workflows/auto-format.yml +++ b/.github/workflows/auto-format.yml @@ -44,7 +44,10 @@ jobs: # The patch belongs to this commit alone; the push job refuses any other head. echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" bun run lint:fix - git add -A + # Anything the formatter left staged is not this workflow's fix: start from an empty index, then stage + # modifications of tracked files alone (a formatter adds, deletes, and renames nothing). + git reset -q + git add -u git diff --cached --binary > "$RUNNER_TEMP/format.patch" if [ -s "$RUNNER_TEMP/format.patch" ]; then git diff --cached --stat @@ -96,6 +99,24 @@ jobs: exit 0 fi git apply --index --binary "$RUNNER_TEMP/format/format.patch" + # The patch was cut on a runner PR code ran on, so it is untrusted: a formatting patch modifies tracked + # files and never touches a workflow; anything else staged is refused. --no-renames lists a rename as its + # delete and add halves, so neither can hide behind an allowed modification. + while IFS= read -r -d '' status && IFS= read -r -d '' path; do + case "$status" in + M) ;; + *) + echo "::error::the format patch stages a '$status' for '$path'; a formatting patch modifies tracked files only; refusing to push" + exit 1 + ;; + esac + case "$path" in + .github/workflows/*) + echo "::error::the format patch touches '$path'; workflows are never formatted here; refusing to push" + exit 1 + ;; + esac + done < <(git diff --cached --no-renames --name-status -z) git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git commit --no-verify -m "style: apply automated formatting" diff --git a/test/docs/repo-owned-workflows.test.ts b/test/docs/repo-owned-workflows.test.ts index 79c35716..525459c6 100644 --- a/test/docs/repo-owned-workflows.test.ts +++ b/test/docs/repo-owned-workflows.test.ts @@ -689,6 +689,108 @@ describe("the commit-back push jobs", () => { expect(format?.outputs?.head).toBe(`\${{ steps.format.outputs.head }}`); const step = format?.steps?.find((candidate) => candidate.id === "format"); expect(step?.run).toContain('echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"'); + // Tracked modifications alone reach the patch; the push side re-checks, since the formatter can stage anything. + expect(step?.run).toContain("git reset -q\ngit add -u\n"); + }); +}); + +/** The push step's staged-path check, from its comment to the loop's end: what runs between the apply and the commit. */ +const PATH_CHECK = + /^# The patch was cut on a runner PR code ran on[\s\S]*?^done < <\(git diff --cached --no-renames --name-status -z\)$/m; + +/** + * The check under `bash -e` in a scratch repository whose index holds `stage`; git's user identity is stubbed through + * the environment so no global config is read. The scratch directory is removed on every path. + */ +function runPathCheck(stage: (repo: string) => void): { status: number; lines: string[] } { + const step = readWorkflow("auto-format.yml").jobs.push?.steps?.find( + (candidate) => candidate.name === "Commit and push the formatting", + ); + const check = (step?.run ?? "").match(PATH_CHECK)?.[0]; + expect(check, "auto-format.yml's push step has no staged-path check").toBeDefined(); + const dir = mkdtempSync(join(tmpdir(), "auto-format-check-")); + const env = { + ...process.env, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@example.invalid", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@example.invalid", + }; + const git = (...args: string[]) => execFileSync("git", args, { cwd: dir, env, stdio: "pipe" }); + try { + git("init", "-q"); + mkdirSync(join(dir, ".github", "workflows"), { recursive: true }); + writeFileSync(join(dir, "a.ts"), "const a = 1\n"); + writeFileSync(join(dir, "b.ts"), "const b = 1\n"); + writeFileSync(join(dir, ".github", "workflows", "ci.yml"), "name: x\n"); + git("add", "-A"); + git("commit", "-q", "-m", "base"); + stage(dir); + git("add", "-A"); + let status = 0; + let stdout = ""; + try { + stdout = execFileSync("bash", ["-e", "-c", check ?? ""], { + cwd: dir, + env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + status = (error as { status?: number }).status ?? -1; + stdout = String((error as { stdout?: string }).stdout ?? ""); + } + return { status, lines: stdout.split("\n").filter(Boolean) }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("auto-format's staged-path check under bash", () => { + test("modifications of tracked files pass silently", () => { + const run = runPathCheck((repo) => { + writeFileSync(join(repo, "a.ts"), "const a = 1;\n"); + writeFileSync(join(repo, "b.ts"), "const b = 1;\n"); + }); + expect(run).toEqual({ status: 0, lines: [] }); + }); + + test.each<[string, (repo: string) => void, string]>([ + [ + "an added file", + (repo) => writeFileSync(join(repo, "extra.ts"), "export {};\n"), + "::error::the format patch stages a 'A' for 'extra.ts'; a formatting patch modifies tracked files only; refusing to push", + ], + [ + "a deleted file", + (repo) => rmSync(join(repo, "b.ts")), + "::error::the format patch stages a 'D' for 'b.ts'; a formatting patch modifies tracked files only; refusing to push", + ], + [ + "a renamed file (its delete half is refused first)", + (repo) => { + rmSync(join(repo, "b.ts")); + writeFileSync(join(repo, "c.ts"), "const b = 1\n"); + }, + "::error::the format patch stages a 'D' for 'b.ts'; a formatting patch modifies tracked files only; refusing to push", + ], + [ + "a workflow modification", + (repo) => writeFileSync(join(repo, ".github", "workflows", "ci.yml"), "name: y\n"), + "::error::the format patch touches '.github/workflows/ci.yml'; workflows are never formatted here; refusing to push", + ], + [ + "an added workflow", + (repo) => writeFileSync(join(repo, ".github", "workflows", "extra.yml"), "name: y\n"), + "::error::the format patch stages a 'A' for '.github/workflows/extra.yml'; a formatting patch modifies tracked files only; refusing to push", + ], + ])("%s is refused with the path named (negative control)", (_, stage, message) => { + const run = runPathCheck((repo) => { + writeFileSync(join(repo, "a.ts"), "const a = 1;\n"); + stage(repo); + }); + expect(run).toEqual({ status: 1, lines: [message] }); }); }); From b55623e43fe691b5f1d7b255966b6d30886795f2 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:29:39 -0400 Subject: [PATCH 7/9] test(docs): cut the repo-owned workflow pins to the invariants no single file shows --- test/docs/repo-owned-workflows.test.ts | 962 ++++--------------------- 1 file changed, 153 insertions(+), 809 deletions(-) diff --git a/test/docs/repo-owned-workflows.test.ts b/test/docs/repo-owned-workflows.test.ts index 525459c6..e90dbcbf 100644 --- a/test/docs/repo-owned-workflows.test.ts +++ b/test/docs/repo-owned-workflows.test.ts @@ -1,906 +1,250 @@ /** - * Every job of a repo-owned workflow goes through the setup composite (or runs no bun at all) and carries a timeout; - * every third-party action is one sha with its version comment across the repo-owned files; lint:yaml runs for real - * where the composite hands the job yamllint. The managed workflows (ci.yml and the fleet's) are the platform's and - * stay out of every pin here. + * The invariants of the repo-owned workflows that no single file shows: every job sets bun up through the one + * composite (or runs no bun), every job is bounded, every action is pinned the same way everywhere, the commit-back + * push jobs run no PR code under their write token, and lint:yaml is real where a job asks for it. The managed + * workflows are the platform's and stay out of every pin here. */ import { describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; -import { - existsSync, - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { isAlias, isMap, isScalar, LineCounter, parseDocument, visit } from "yaml"; +import { isAlias, isMap, isScalar, parseDocument, visit } from "yaml"; import { - type CompositeAction, - installs, isManaged, type Job, ROOT, - readAction, readWorkflow, repoOwnedWorkflowFiles, SETUP_USES, type Step, - setupInstalls, setupYamllint, workflowFiles, workflowText, } from "./workflow-loader.js"; -const SETUP_DIR = ".github/actions/setup"; -const ACTIONS_DIR = ".github/actions"; -const YAMLLINT_VERSION = "1.38.0"; -/** The composite's header states the rule: 15 minutes unless the job's comment says why it needs more. */ -const DEFAULT_TIMEOUT = 15; - -const PACKAGE_SCRIPTS = ( +const SCRIPTS = ( JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { scripts: Record; } ).scripts; - -/** - * A job's standing to the composite, or the defect found instead. - * install -> "composite" (the composite's), "own" (a run step's: float-canary re-resolves from scratch), "none" - * "no bun" -> a job no step of which runs bun (a git-only push, a gh-only reporter): nothing to set up - */ -type Setup = { install: string; yamllint: boolean; if: string | undefined } | string; -interface JobPin { - timeout: number | undefined; - setup: Setup; -} - -const composite = (over: Partial> = {}): Setup => ({ - install: "composite", - yamllint: false, - if: undefined, - ...over, -}); - -const EXPECTED: Record> = { - "auto-fix.yml": { - build: { timeout: 15, setup: composite() }, - // Downloads the patch and pushes with git alone: no install where the write token is. - push: { timeout: 10, setup: "no bun" }, - }, - "auto-format.yml": { - format: { timeout: 15, setup: composite() }, - // Applies the format job's patch and pushes; no PR code runs where the write token is. - push: { timeout: 10, setup: "no bun" }, - }, - "checks.yml": { - check: { timeout: 15, setup: composite({ yamllint: true }) }, - "boundary-check": { timeout: 5, setup: composite({ install: "none" }) }, - "anchor-check": { timeout: 5, setup: composite({ install: "none" }) }, - "schema-check": { timeout: 15, setup: composite() }, - "package-smoke": { timeout: 15, setup: composite() }, - "self-check": { timeout: 15, setup: composite() }, - "e2e-smoke": { timeout: 15, setup: composite() }, - "endpoint-coverage": { timeout: 15, setup: composite() }, - }, - "copilot-setup-steps.yml": { "copilot-setup-steps": { timeout: 59, setup: composite() } }, - "e2e-nightly.yml": { nightly: { timeout: 15, setup: composite() } }, - "nightly-fuzz.yml": { fuzz: { timeout: 60, setup: composite() } }, - "nightly.yml": { - checks: { timeout: 15, setup: composite() }, - "float-canary": { timeout: 15, setup: composite({ install: "own", yamllint: true }) }, - // A few gh calls and the fleet's issue action; no checkout, no bun. - report: { timeout: 5, setup: "no bun" }, - }, - "post-green.yml": { - build: { timeout: 10, setup: composite({ if: "steps.token.outputs.proceed == 'true'" }) }, - "publish-next": { - timeout: 10, - setup: composite({ if: "steps.oidc.outputs.proceed == 'true'" }), - }, - }, - "update-release-pr.yml": { anchor: { timeout: 5, setup: composite({ install: "none" }) } }, - "update-release.yml": { - "package-release": { timeout: 15, setup: composite() }, - "verify-release": { timeout: 5, setup: composite({ install: "none" }) }, - "publish-npm": { timeout: 10, setup: composite() }, - }, -}; - -/** The jobs above the default, each with its reason in the workflow beside the value. */ -const LONGER: Record = { - // Copilot's documented ceiling for this job. - "copilot-setup-steps.yml#copilot-setup-steps": 59, - // A 50-minute fuzz run bounded below it, so a hang fails instead of cancelling. - "nightly-fuzz.yml#fuzz": 60, -}; - -const MANAGED_FILES = ["auto-assign.yml", "ci.yml", "pr-title.yml"]; -const SETUP_INPUTS = ["install", "yamllint"]; - /** A bun invocation the shell would run: the word `bun` at a command position. */ const RUNS_BUN = /(?:^|[\s;&|(])bun(?=\s|$)/m; -const isSetupBun = (step: Step) => (step.uses ?? "").startsWith("oven-sh/setup-bun@"); const isSetup = (step: Step) => step.uses === SETUP_USES; /** A step that needs bun on the runner: a run step invoking it, or a local composite (each of ours runs bun). */ const needsBun = (step: Step) => RUNS_BUN.test(step.run ?? "") || (!isSetup(step) && (step.uses ?? "").startsWith("./")); -function setupOf(job: Job): Setup { - const steps = job.steps ?? []; +/** Why a job's bun setup is wrong, or undefined: one composite step ahead of every bun user, or no bun at all. */ +function setupProblem(steps: Step[]): string | undefined { const setups = steps.filter(isSetup); - const own = steps.some((step) => installs(step.run)); - if (setups.length !== 1) { - return setups.length === 0 && !steps.some(needsBun) && !steps.some(isSetupBun) - ? "no bun" - : `${setups.length} setup steps for a job running bun`; - } - if (steps.some(isSetupBun)) { - return "oven-sh/setup-bun outside the composite"; - } - const setup = setups[0] as Step; - const early = steps.slice(0, steps.indexOf(setup)).filter(needsBun); - if (early.length > 0) { - return `bun runs before the setup composite: ${early.map((step) => step.name ?? step.uses ?? step.run).join("; ")}`; - } - const unknown = Object.keys(setup.with ?? {}).filter((key) => !SETUP_INPUTS.includes(key)); - if (unknown.length > 0) { - return `unknown setup inputs: ${unknown.join(", ")}`; - } - // An expression's value is the runner's to compute; the pin can only read a literal. - const expressions = SETUP_INPUTS.filter((key) => String(setup.with?.[key] ?? "").includes("${{")); - if (expressions.length > 0) { - return `setup inputs given as expressions, not literals: ${expressions.join(", ")}`; - } - const install = - [setupInstalls(setup) ? "composite" : "", own ? "own" : ""].filter(Boolean).join(" and ") || - "none"; - return { install, yamllint: setupYamllint(setup), if: setup.if }; -} - -function pinOf(job: Job): JobPin { - return { timeout: job["timeout-minutes"], setup: setupOf(job) }; + if (steps.some((step) => (step.uses ?? "").startsWith("oven-sh/setup-bun@"))) + return "setup-bun outside the composite"; + if (setups.length === 0 && !steps.some(needsBun)) return undefined; + if (setups.length !== 1) return `${setups.length} composite steps for a bun job`; + const early = steps.slice(0, steps.indexOf(setups[0] as Step)).filter(needsBun); + return early.length > 0 + ? `bun runs before the composite: ${early.map((s) => s.name ?? s.uses ?? s.run).join("; ")}` + : undefined; } -function pinsOf(file: string): Record { - return Object.fromEntries( - Object.entries(readWorkflow(file).jobs).map(([id, job]) => [id, pinOf(job)]), +/** `#` and the job, over every repo-owned workflow. */ +const repoOwnedJobs = (): Array<[string, Job]> => + repoOwnedWorkflowFiles().flatMap((file) => + Object.entries(readWorkflow(file).jobs).map(([id, job]): [string, Job] => [ + `${file}#${id}`, + job, + ]), ); -} +const jobsWhere = (test: (step: Step) => boolean) => + repoOwnedJobs().flatMap(([where, job]) => ((job.steps ?? []).some(test) ? [where] : [])); describe("the repo-owned workflows", () => { - test("the files split into exactly the pinned repo-owned and managed sets", () => { - expect(repoOwnedWorkflowFiles()).toEqual(Object.keys(EXPECTED).sort()); - expect(workflowFiles().filter((file) => isManaged(workflowText(file)))).toEqual(MANAGED_FILES); - }); - - test("every job goes through the setup composite once (or runs no bun) and carries its pinned timeout", () => { - expect( - Object.fromEntries(repoOwnedWorkflowFiles().map((file) => [file, pinsOf(file)])), - ).toEqual(EXPECTED); + test("the managed set is exactly the files whose header says so", () => { + expect(workflowFiles().filter((file) => isManaged(workflowText(file)))).toEqual([ + "auto-assign.yml", + "ci.yml", + "pr-title.yml", + ]); }); - test(`timeouts above ${DEFAULT_TIMEOUT} minutes are exactly the jobs whose comment says why`, () => { - const pins = Object.entries(EXPECTED).flatMap(([file, jobs]) => - Object.entries(jobs).map(([id, pin]) => [`${file}#${id}`, pin] as const), - ); - for (const [where, pin] of pins) { - expect(pin.timeout, `${where} has no timeout`).toBeGreaterThan(0); + test("every job sets bun up through the composite once, or runs no bun; every job carries a timeout", () => { + for (const [where, job] of repoOwnedJobs()) { + expect(setupProblem(job.steps ?? []), where).toBeUndefined(); + expect(job["timeout-minutes"], `${where} has no timeout-minutes`).toBeGreaterThan(0); } - expect( - Object.fromEntries( - pins - .filter(([, pin]) => (pin.timeout ?? 0) > DEFAULT_TIMEOUT) - .map(([where, pin]) => [where, pin.timeout]), - ), - ).toEqual(LONGER); - }); - - const check = () => structuredClone(readWorkflow("checks.yml").jobs.check as Job); - const setupStep = (job: Job) => (job.steps ?? []).find(isSetup) as Step; - - test.each<[string, (job: Job) => void, JobPin]>([ - [ - "a dropped timeout", - (job) => delete job["timeout-minutes"], - { timeout: undefined, setup: composite({ yamllint: true }) }, - ], - [ - "a job running bun without the composite", - (job) => (job.steps = job.steps?.filter((step) => !isSetup(step))), - { timeout: 15, setup: "0 setup steps for a job running bun" }, - ], - [ - "a duplicated composite", - (job) => job.steps?.push({ uses: SETUP_USES }), - { timeout: 15, setup: "2 setup steps for a job running bun" }, - ], - [ - "the composite moved after the steps that need it", - (job) => { - const steps = job.steps ?? []; - const [setup] = steps.splice(steps.findIndex(isSetup), 1); - steps.push(setup as Step); - }, - { - timeout: 15, - setup: - "bun runs before the setup composite: ./.github/actions/fetch-test-artifacts; Lint (biome); Lint (yaml); " + - "Lint (architecture); Typecheck; Dead code (knip); Compat markers; Test", - }, - ], - [ - "one bun step ahead of the composite", - (job) => job.steps?.splice(1, 0, { run: "bun --version" }), - { timeout: 15, setup: "bun runs before the setup composite: bun --version" }, - ], - [ - "setup-bun beside the composite", - (job) => - job.steps?.push({ uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6" }), - { timeout: 15, setup: "oven-sh/setup-bun outside the composite" }, - ], - [ - "setup-bun instead of the composite", - (job) => - (job.steps = job.steps?.map((step) => - isSetup(step) - ? { uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6" } - : step, - )), - { timeout: 15, setup: "0 setup steps for a job running bun" }, - ], - [ - "an inline install beside the composite's", - (job) => job.steps?.push({ run: "bun install --frozen-lockfile" }), - { timeout: 15, setup: composite({ install: "composite and own", yamllint: true }) }, - ], - [ - "a misspelled input", - (job) => (setupStep(job).with = { yamlint: "true" }), - { timeout: 15, setup: "unknown setup inputs: yamlint" }, - ], - [ - "an input given as an expression the runner would evaluate to false", - (job) => (setupStep(job).with = { install: `\${{ 'false' }}`, yamllint: "true" }), - { timeout: 15, setup: "setup inputs given as expressions, not literals: install" }, - ], - [ - "yamllint switched off", - (job) => (setupStep(job).with = { yamllint: "false" }), - { timeout: 15, setup: composite() }, - ], - [ - "the install switched off", - (job) => (setupStep(job).with = { install: "false", yamllint: "true" }), - { timeout: 15, setup: composite({ install: "none", yamllint: true }) }, - ], - // GitHub compares expression strings without regard to letter case, so these spellings switch at runtime too. - [ - "the install switched off in capitals", - (job) => (setupStep(job).with = { install: "FALSE", yamllint: "true" }), - { timeout: 15, setup: composite({ install: "none", yamllint: true }) }, - ], - [ - "yamllint switched off in capitals", - (job) => (setupStep(job).with = { yamllint: "False" }), - { timeout: 15, setup: composite() }, - ], - [ - "a gated setup", - (job) => (setupStep(job).if = "github.event_name == 'push'"), - { timeout: 15, setup: composite({ yamllint: true, if: "github.event_name == 'push'" }) }, - ], - ])("%s derives away from the pin (negative control)", (_, mutate, derived) => { - const job = check(); - mutate(job); - expect(pinOf(job)).toEqual(derived); - expect(pinOf(job)).not.toEqual(EXPECTED["checks.yml"]?.check as JobPin); - }); - - test("a job with no bun anywhere derives to no bun, and one bun call derives away (negative control)", () => { - const job: Job = { - "timeout-minutes": 5, - steps: [{ run: "gh pr list" }, { uses: "actions/checkout@0" }], - }; - expect(setupOf(job)).toBe("no bun"); - job.steps?.push({ run: "gh pr list\nbun .github/scripts/release-pipeline.ts verify" }); - expect(setupOf(job)).toBe("0 setup steps for a job running bun"); - }); - - test("a local composite (which runs bun) without the setup derives away from no bun (negative control)", () => { - const job: Job = { - "timeout-minutes": 5, - steps: [{ uses: "actions/checkout@0" }, { uses: "./.github/actions/fetch-test-artifacts" }], - }; - expect(setupOf(job)).toBe("0 setup steps for a job running bun"); - }); - - test.each([ - ["a bare command", true, "bun test"], - ["a command inside a script", true, "echo start\nbun run check"], - ["a command after a separator", true, "cd lib && bun install"], - ["a command inside a subshell", true, "out=$(bun --version)"], - ["the lockfile name", false, "rm bun.lock"], - ["a word containing it", false, "echo ubuntu bundle"], - ["a comment naming it", true, "# bun runs here"], - ])("RUNS_BUN: %s -> %p", (_, expected, run) => { - expect(RUNS_BUN.test(run)).toBe(expected); + // Controls: the derivation sees the composite, and each drift class it exists for is a problem. + expect(jobsWhere(isSetup).length).toBeGreaterThan(15); + const drifts: Step[][] = [ + [{ uses: SETUP_USES }, { uses: "oven-sh/setup-bun@0000" }], + [{ uses: "./.github/actions/fetch-test-artifacts" }], + [{ run: "bun --version" }, { uses: SETUP_USES }], + ]; + for (const steps of drifts) expect(setupProblem(steps), JSON.stringify(steps)).toBeDefined(); }); }); -/** One `uses:` value and the comment on its line (the text after `#`, trimmed): the version pin lives in that comment. */ -interface UsesLine { - where: string; - uses: string; - comment: string; -} - -function actionDirs(): string[] { - return readdirSync(join(ROOT, ACTIONS_DIR)) - .map((name) => `${ACTIONS_DIR}/${name}`) - .sort(); -} - -/** Every `uses:` in the YAML syntax tree, block or flow style, an alias resolved, with the comment on its line. */ -function usesLinesIn(where: string, text: string): UsesLine[] { - const lineCounter = new LineCounter(); - const doc = parseDocument(text, { lineCounter }); - const lines: UsesLine[] = []; +/** Every `uses:` value in a YAML file with the comment on its line: block or flow style, an alias resolved. */ +function usesIn(text: string): Array<[string, string]> { + const doc = parseDocument(text); + const found: Array<[string, string]> = []; visit(doc, { Pair(_, pair, path) { - const written = pair.value; const key = isAlias(pair.key) ? pair.key.resolve(doc) : pair.key; - if (!isScalar(key) || key.value !== "uses") { - return; - } - if (!(isScalar(written) || isAlias(written))) { + const written = pair.value; + if (!isScalar(key) || key.value !== "uses" || !(isScalar(written) || isAlias(written))) return; - } const node = isAlias(written) ? written.resolve(doc) : written; - if (!isScalar(node)) { - return; - } // A flow step's comment (`- {uses: x} # v1`) sits on the enclosing map, not on the scalar. const parent = path[path.length - 1]; const comment = written.comment ?? (isMap(parent) && parent.flow ? parent.comment : undefined) ?? ""; - lines.push({ - where: `${where}:${lineCounter.linePos(written.range?.[0] ?? 0).line}`, - uses: String(node.value), - comment: comment.trim(), - }); + if (isScalar(node)) found.push([String(node.value), comment.trim()]); }, }); - return lines; -} - -function usesLines(): UsesLine[] { - return [ - ...repoOwnedWorkflowFiles().flatMap((file) => - usesLinesIn(`.github/workflows/${file}`, workflowText(file)), - ), - ...actionDirs().flatMap((dir) => - usesLinesIn(`${dir}/action.yml`, readFileSync(join(ROOT, dir, "action.yml"), "utf8")), - ), - ]; + return found; } const SHA_PIN = /^[\w.-]+\/[\w.-]+(?:\/[\w./-]+)?@[0-9a-f]{40}$/; -const VERSION_COMMENT = /^v\d+(?:\.\d+)*$/; /** The fleet's own actions ride their stable channel by design; any other ref is a pin problem. */ const FLEET_STABLE = /^Vivswan\/repo-platform\/actions\/[\w-]+@stable$/; -/** Why a `uses:` fails the pin policy, or undefined when it passes. */ -function pinProblem({ uses, comment }: Pick): string | undefined { - if (uses.startsWith("./")) { +function pinProblem(uses: string, comment: string): string | undefined { + if (uses.startsWith("./")) return comment === "" ? undefined : "a local action carries no version comment"; - } - if (FLEET_STABLE.test(uses)) { - return undefined; - } - if (!SHA_PIN.test(uses)) { - return "not a full-sha pin"; - } - if (!VERSION_COMMENT.test(comment)) { - return "no version comment after the sha"; - } - return undefined; + if (FLEET_STABLE.test(uses)) return undefined; + if (!SHA_PIN.test(uses)) return "not a full-sha pin"; + return /^v\d+(?:\.\d+)*$/.test(comment) ? undefined : "no version comment after the sha"; } describe("action pins across the repo-owned files", () => { - test("every uses: is a local path, the fleet's action on its branch, or a full sha with its version comment", () => { - const lines = usesLines(); - // Control: the scan reads real lines (a regex drift would pass vacuously). - expect(lines.length).toBeGreaterThan(30); - expect(lines.some(({ uses }) => uses === SETUP_USES)).toBe(true); - expect( - lines.flatMap((line) => { - const problem = pinProblem(line); - return problem ? [`${line.where}: ${line.uses} (${problem})`] : []; - }), - ).toEqual([]); - }); - - test("the scan reads block, flow, and aliased steps alike, with the comment and line of each (control)", () => { - const text = [ - "steps:", - ` - uses: actions/checkout@${"a".repeat(40)} # v7.0.1`, - " with: {ref: main}", - " - {uses: actions/checkout@v7}", - ` - {uses: actions/checkout@${"b".repeat(40)}, with: {ref: main}} # v7.0.2`, - " - name: &action actions/checkout@v8", - " uses: *action # v8.0.0", - " - name: &key uses", - " *key : actions/checkout@v9", - " # uses: commented/out@v1", - " - uses: ./.github/actions/setup", - "", - ].join("\n"); - expect(usesLinesIn("x.yml", text)).toEqual([ - { where: "x.yml:2", uses: `actions/checkout@${"a".repeat(40)}`, comment: "v7.0.1" }, - { where: "x.yml:4", uses: "actions/checkout@v7", comment: "" }, - { where: "x.yml:5", uses: `actions/checkout@${"b".repeat(40)}`, comment: "v7.0.2" }, - { where: "x.yml:7", uses: "actions/checkout@v8", comment: "v8.0.0" }, - { where: "x.yml:9", uses: "actions/checkout@v9", comment: "" }, - { where: "x.yml:11", uses: "./.github/actions/setup", comment: "" }, - ]); - }); - - test.each([ - ["a tag ref", "actions/checkout@v7", "", "not a full-sha pin"], - ["a short sha", "actions/checkout@3d3c42e", "v7.0.1", "not a full-sha pin"], - [ - "a sha without its comment", - `actions/checkout@${"a".repeat(40)}`, - "", - "no version comment after the sha", - ], - [ - "a sha with a prose comment", - `actions/checkout@${"a".repeat(40)}`, - "pinned", - "no version comment after the sha", - ], - [ - "the fleet's action at a tag", - "Vivswan/repo-platform/actions/fuzz-issue@v1", - "", - "not a full-sha pin", - ], - [ - "the fleet's action on its stable channel", - "Vivswan/repo-platform/actions/fuzz-issue@stable", - "", - undefined, - ], - [ - "the fleet's action on the branch it left", - "Vivswan/repo-platform/actions/fuzz-issue@build", - "", - "not a full-sha pin", - ], - [ - "the fleet's action at a sha", - `Vivswan/repo-platform/actions/fuzz-issue@${"b".repeat(40)}`, - "v1.2.0", - undefined, - ], - ["a local action", "./.github/actions/setup", "", undefined], - [ - "a local action with a comment", - "./.github/actions/setup", - "v1", - "a local action carries no version comment", - ], - ["the action root", "./", "", undefined], - ])("pinProblem(): %s", (_, uses, comment, problem) => { - expect(pinProblem({ uses, comment })).toBe(problem); - }); - - test("each third-party action is one sha and one version everywhere it appears", () => { - const byAction = new Map>(); - for (const { uses, comment } of usesLines()) { - if (!SHA_PIN.test(uses)) { - continue; - } - const [action, sha] = uses.split("@") as [string, string]; - byAction.set(action, (byAction.get(action) ?? new Set()).add(`${sha} ${comment}`)); - } - expect([...byAction.keys()].sort()).toEqual([ - "actions/cache", - "actions/checkout", - "actions/download-artifact", - "actions/setup-node", - "actions/upload-artifact", - "marocchino/sticky-pull-request-comment", - "oven-sh/setup-bun", - ]); - for (const [action, pins] of byAction) { - expect([...pins], `${action} is pinned ${pins.size} ways`).toHaveLength(1); - } - }); -}); - -/** A composite step as the pin reads it: the fields that decide what runs and whether a failure counts. */ -interface StepShape { - uses: string | undefined; - with: Record | undefined; - if: string | undefined; - shell: string | undefined; - run: string | undefined; - "continue-on-error": boolean | undefined; -} - -function shapeOf(steps: Step[]): StepShape[] { - return steps.map((step) => ({ - uses: step.uses, - with: step.with, - if: step.if, - shell: step.shell, - run: step.run, - "continue-on-error": step["continue-on-error"], - })); -} - -/** The composite's three steps: none may mask its failure, since a job relies on each one it asks for. */ -const SETUP_SHAPE: StepShape[] = [ - { - uses: expect.stringMatching(/^oven-sh\/setup-bun@[0-9a-f]{40}$/), - with: { "bun-version-file": ".bun-version" }, - if: undefined, - shell: undefined, - run: undefined, - "continue-on-error": undefined, - }, - { - uses: undefined, - with: undefined, - if: "inputs.install != 'false'", - shell: "bash", - run: "bun install --frozen-lockfile --ignore-scripts", - "continue-on-error": undefined, - }, - { - uses: undefined, - with: undefined, - if: "inputs.yamllint == 'true'", - shell: "bash", - run: `pipx install yamllint==${YAMLLINT_VERSION}`, - "continue-on-error": undefined, - }, -]; - -describe("the setup composite", () => { - const action = readAction(SETUP_DIR); + const files = [ + ...repoOwnedWorkflowFiles().map((file) => `.github/workflows/${file}`), + ...readdirSync(join(ROOT, ".github/actions")).map( + (name) => `.github/actions/${name}/action.yml`, + ), + ]; + const lines = files.flatMap((file) => + usesIn(readFileSync(join(ROOT, file), "utf8")).map(([uses, comment]) => ({ + file, + uses, + comment, + })), + ); - test("declares the two inputs with their defaults and runs bun, the gated install, and the gated yamllint", () => { - expect(action.runs.using).toBe("composite"); - expect(action.inputs).toEqual({ - install: { description: expect.any(String), default: "true" }, - yamllint: { description: expect.any(String), default: "false" }, + test("every uses: is a local path, the fleet action at @stable, or a full sha with its version comment; one sha per action", () => { + expect(lines.length).toBeGreaterThan(30); + const problems = lines.flatMap(({ file, uses, comment }) => { + const problem = pinProblem(uses, comment); + return problem ? [`${file}: ${uses} (${problem})`] : []; }); - expect(shapeOf(action.runs.steps ?? [])).toEqual(SETUP_SHAPE); - }); - - test.each<[string, (step: Step) => Step]>([ - ["an install allowed to fail", (step) => ({ ...step, "continue-on-error": true })], - ["an install with its lockfile unfrozen", (step) => ({ ...step, run: "bun install" })], - [ - "an install that runs lifecycle scripts", - (step) => ({ ...step, run: "bun install --frozen-lockfile" }), - ], - [ - "an install gated on a different spelling", - (step) => ({ ...step, if: "inputs.install == 'true'" }), - ], - ])("%s inside the composite fails the shape pin (negative control)", (_, mutate) => { - const steps = (action.runs.steps ?? []).map((step) => - step.run?.startsWith("bun install") ? mutate(step) : step, - ); - expect(() => expect(shapeOf(steps)).toEqual(SETUP_SHAPE)).toThrow(); - }); - - test("every run step of every composite names its shell (the runner rejects one without at job start)", () => { - for (const dir of actionDirs()) { - for (const step of readAction(dir).runs.steps ?? []) { - if (step.run !== undefined) { - expect(step.shell, `${dir}: step ${step.name ?? step.id ?? step.run}`).toBe("bash"); - } - } + expect(problems).toEqual([]); + expect(pinProblem("Vivswan/repo-platform/actions/fuzz-issue@build", "")).toBeDefined(); + const pins = new Map>(); + for (const { uses, comment } of lines.filter((line) => SHA_PIN.test(line.uses))) { + const [action, sha] = uses.split("@") as [string, string]; + pins.set(action, (pins.get(action) ?? new Set()).add(`${sha} ${comment}`)); } - }); - - /** The composites (by directory) whose steps run oven-sh/setup-bun: a second one would replace the pinned bun. */ - const setupBunHosts = (actions: ReadonlyArray<[string, CompositeAction]>) => - actions.filter(([, action]) => (action.runs.steps ?? []).some(isSetupBun)).map(([dir]) => dir); - - test("oven-sh/setup-bun runs in the setup composite and nowhere else", () => { - expect(setupBunHosts(actionDirs().map((dir) => [dir, readAction(dir)]))).toEqual([SETUP_DIR]); - }); - - test("a setup-bun added to another composite is reported by directory (negative control)", () => { - const fetch = structuredClone(readAction(`${ACTIONS_DIR}/fetch-test-artifacts`)); - fetch.runs.steps?.unshift({ - uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", - with: { "bun-version": "latest" }, - }); - expect( - setupBunHosts([ - [`${ACTIONS_DIR}/fetch-test-artifacts`, fetch], - [SETUP_DIR, action], - ]), - ).toEqual([`${ACTIONS_DIR}/fetch-test-artifacts`, SETUP_DIR]); + expect(pins.size).toBeGreaterThan(3); + for (const [action, shas] of pins) + expect([...shas], `${action} is pinned ${shas.size} ways`).toHaveLength(1); }); }); -/** A job's run steps name lint:yaml, directly or through `bun run check`. */ -const RUNS_LINT_YAML = /\bbun run (?:check|lint:yaml)(?![\w:.-])/; - -/** - * The commit-back push jobs: the patch was cut on one head, so a head that moved before the push skips with a notice, - * and the push itself is leased to that head. Both workflows spell the guard the same way. - */ -const HEAD_MOVED_GUARD = - /^if \[ "\$\(git rev-parse HEAD\)" != "\$HEAD_SHA" \]; then\n {2}echo "::notice::head moved .*"\n {2}exit 0\nfi$/m; -const LEASED_PUSH = /git push --force-with-lease="refs\/heads\/\$\{HEAD_REF\}:\$\{HEAD_SHA\}"/; +/** The commit-back push jobs: [file, push step name, where HEAD_SHA comes from]. */ const PUSH_JOBS: ReadonlyArray<[string, string, string]> = [ ["auto-fix.yml", "Commit and push the fix", `\${{ github.event.pull_request.head.sha }}`], ["auto-format.yml", "Commit and push the formatting", `\${{ needs.format.outputs.head }}`], ]; +const HEAD_MOVED_GUARD = + /^if \[ "\$\(git rev-parse HEAD\)" != "\$HEAD_SHA" \]; then\n {2}echo "::notice::head moved .*"\n {2}exit 0\nfi$/m; +const LEASED_PUSH = /git push --force-with-lease="refs\/heads\/\$\{HEAD_REF\}:\$\{HEAD_SHA\}"/; +/** auto-format's staged-path check between the apply and the commit: an untrusted patch may modify tracked files only. */ +const PATH_CHECK = + /^# The patch was cut on a runner PR code ran on[\s\S]*?^done < <\(git diff --cached --no-renames --name-status -z\)$/m; describe("the commit-back push jobs", () => { test.each(PUSH_JOBS)( - "%s: the push step skips on a moved head and leases the push to it", + "%s: no PR code under the write token, skips on a moved head, leases the push", (file, name, sha) => { - const step = readWorkflow(file).jobs.push?.steps?.find( - (candidate) => candidate.name === name, - ); - expect(step, `${file} has no step named ${name}`).toBeDefined(); + const push = readWorkflow(file).jobs.push; + expect(push?.permissions?.contents).toBe("write"); + expect((push?.steps ?? []).filter(needsBun)).toEqual([]); + const step = push?.steps?.find((candidate) => candidate.name === name); expect(step?.env?.HEAD_SHA).toBe(sha); const run = step?.run ?? ""; - expect(run, `${file}: no head-moved guard`).toMatch(HEAD_MOVED_GUARD); - expect(run, `${file}: the push is not leased to HEAD_SHA`).toMatch(LEASED_PUSH); - // The guard runs before anything is applied or pushed. + expect(run).toMatch(HEAD_MOVED_GUARD); + expect(run).toMatch(LEASED_PUSH); expect(run.search(HEAD_MOVED_GUARD)).toBeLessThan(run.indexOf("git apply")); }, ); - test.each([ - [ - "a guard that only warns", - 'if [ "$(git rev-parse HEAD)" != "$HEAD_SHA" ]; then\n echo "::notice::head moved since the format"\nfi', - ], - [ - "a guard on the wrong variable", - 'if [ "$(git rev-parse HEAD)" != "$BASE_SHA" ]; then\n echo "::notice::head moved since the format"\n exit 0\nfi', - ], - ["no guard", 'git apply --index --binary "$RUNNER_TEMP/format/format.patch"'], - ])("%s fails the guard pin (negative control)", (_, run) => { - expect(run).not.toMatch(HEAD_MOVED_GUARD); - }); - - test("the format job hands its head to the push job (control)", () => { - const format = readWorkflow("auto-format.yml").jobs.format; - expect(format?.outputs?.head).toBe(`\${{ steps.format.outputs.head }}`); - const step = format?.steps?.find((candidate) => candidate.id === "format"); - expect(step?.run).toContain('echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"'); - // Tracked modifications alone reach the patch; the push side re-checks, since the formatter can stage anything. - expect(step?.run).toContain("git reset -q\ngit add -u\n"); + test("auto-format checks the staged paths after the apply and before the commit", () => { + const push = readWorkflow("auto-format.yml").jobs.push; + const run = + push?.steps?.find((step) => step.name === "Commit and push the formatting")?.run ?? ""; + expect(run.search(PATH_CHECK)).toBeGreaterThan(run.indexOf("git apply")); + expect(run.search(PATH_CHECK)).toBeLessThan(run.indexOf("git commit")); + expect(run).toContain("M) ;;"); + expect(run).toContain(".github/workflows/*)"); }); }); -/** The push step's staged-path check, from its comment to the loop's end: what runs between the apply and the commit. */ -const PATH_CHECK = - /^# The patch was cut on a runner PR code ran on[\s\S]*?^done < <\(git diff --cached --no-renames --name-status -z\)$/m; - -/** - * The check under `bash -e` in a scratch repository whose index holds `stage`; git's user identity is stubbed through - * the environment so no global config is read. The scratch directory is removed on every path. - */ -function runPathCheck(stage: (repo: string) => void): { status: number; lines: string[] } { - const step = readWorkflow("auto-format.yml").jobs.push?.steps?.find( - (candidate) => candidate.name === "Commit and push the formatting", - ); - const check = (step?.run ?? "").match(PATH_CHECK)?.[0]; - expect(check, "auto-format.yml's push step has no staged-path check").toBeDefined(); - const dir = mkdtempSync(join(tmpdir(), "auto-format-check-")); - const env = { - ...process.env, - GIT_CONFIG_GLOBAL: "/dev/null", - GIT_AUTHOR_NAME: "t", - GIT_AUTHOR_EMAIL: "t@example.invalid", - GIT_COMMITTER_NAME: "t", - GIT_COMMITTER_EMAIL: "t@example.invalid", - }; - const git = (...args: string[]) => execFileSync("git", args, { cwd: dir, env, stdio: "pipe" }); - try { - git("init", "-q"); - mkdirSync(join(dir, ".github", "workflows"), { recursive: true }); - writeFileSync(join(dir, "a.ts"), "const a = 1\n"); - writeFileSync(join(dir, "b.ts"), "const b = 1\n"); - writeFileSync(join(dir, ".github", "workflows", "ci.yml"), "name: x\n"); - git("add", "-A"); - git("commit", "-q", "-m", "base"); - stage(dir); - git("add", "-A"); - let status = 0; - let stdout = ""; - try { - stdout = execFileSync("bash", ["-e", "-c", check ?? ""], { - cwd: dir, - env, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); - } catch (error) { - status = (error as { status?: number }).status ?? -1; - stdout = String((error as { stdout?: string }).stdout ?? ""); - } - return { status, lines: stdout.split("\n").filter(Boolean) }; - } finally { - rmSync(dir, { recursive: true, force: true }); - } -} - -describe("auto-format's staged-path check under bash", () => { - test("modifications of tracked files pass silently", () => { - const run = runPathCheck((repo) => { - writeFileSync(join(repo, "a.ts"), "const a = 1;\n"); - writeFileSync(join(repo, "b.ts"), "const b = 1;\n"); - }); - expect(run).toEqual({ status: 0, lines: [] }); - }); - - test.each<[string, (repo: string) => void, string]>([ - [ - "an added file", - (repo) => writeFileSync(join(repo, "extra.ts"), "export {};\n"), - "::error::the format patch stages a 'A' for 'extra.ts'; a formatting patch modifies tracked files only; refusing to push", - ], - [ - "a deleted file", - (repo) => rmSync(join(repo, "b.ts")), - "::error::the format patch stages a 'D' for 'b.ts'; a formatting patch modifies tracked files only; refusing to push", - ], - [ - "a renamed file (its delete half is refused first)", - (repo) => { - rmSync(join(repo, "b.ts")); - writeFileSync(join(repo, "c.ts"), "const b = 1\n"); - }, - "::error::the format patch stages a 'D' for 'b.ts'; a formatting patch modifies tracked files only; refusing to push", - ], - [ - "a workflow modification", - (repo) => writeFileSync(join(repo, ".github", "workflows", "ci.yml"), "name: y\n"), - "::error::the format patch touches '.github/workflows/ci.yml'; workflows are never formatted here; refusing to push", - ], - [ - "an added workflow", - (repo) => writeFileSync(join(repo, ".github", "workflows", "extra.yml"), "name: y\n"), - "::error::the format patch stages a 'A' for '.github/workflows/extra.yml'; a formatting patch modifies tracked files only; refusing to push", - ], - ])("%s is refused with the path named (negative control)", (_, stage, message) => { - const run = runPathCheck((repo) => { - writeFileSync(join(repo, "a.ts"), "const a = 1;\n"); - stage(repo); - }); - expect(run).toEqual({ status: 1, lines: [message] }); +describe("lint:yaml", () => { + test("the composite hands yamllint to exactly the jobs that run it", () => { + const running = jobsWhere((step) => + /\bbun run (?:check|lint:yaml)(?![\w:.-])/.test(step.run ?? ""), + ); + expect(running).toEqual(["checks.yml#check", "nightly.yml#float-canary"]); + expect(jobsWhere(setupYamllint)).toEqual(running); + expect(SCRIPTS.check).toContain("bun run lint:yaml"); }); -}); -/** `#` for every repo-owned job with a step matching `test`. */ -function jobsWhere(test: (step: Step) => boolean): string[] { - return repoOwnedWorkflowFiles() - .flatMap((file) => - Object.entries(readWorkflow(file).jobs) - .filter(([, job]) => (job.steps ?? []).some(test)) - .map(([id]) => `${file}#${id}`), - ) - .sort(); -} - -interface LintYamlRun { - status: number; - stdout: string; - stderr: string; - /** The arguments the yamllint stub saw, one per line; undefined when it was never called. */ - calls: string[] | undefined; -} - -/** - * The lint:yaml script under bash from the repository root, PATH cut to the system directories plus a stub bin: with - * the stub, yamllint records its arguments; without it, `command -v yamllint` fails. The scratch directory is - * removed on every path. - */ -function runLintYaml(env: Record, stub: boolean): LintYamlRun { - const dir = mkdtempSync(join(tmpdir(), "lint-yaml-")); - try { - const bin = join(dir, "bin"); - mkdirSync(bin); - const calls = join(dir, "calls"); - if (stub) { - writeFileSync(join(bin, "yamllint"), `#!/bin/sh\nprintf '%s\\n' "$@" > "${calls}"\n`, { - mode: 0o755, - }); - } - let status = 0; - let stdout = ""; - let stderr = ""; + /** The script from the repository root with PATH cut to the system directories plus a stub bin (a yamllint that reports its call, or nothing). */ + function lintYaml( + env: Record, + stub: boolean, + ): { status: number; lines: string[] } { + const dir = mkdtempSync(join(tmpdir(), "lint-yaml-")); try { - stdout = execFileSync("bash", ["-c", PACKAGE_SCRIPTS["lint:yaml"] ?? ""], { + mkdirSync(join(dir, "bin")); + if (stub) + writeFileSync(join(dir, "bin/yamllint"), '#!/bin/sh\necho "yamllint $1"\n', { + mode: 0o755, + }); + const options = { cwd: ROOT, encoding: "utf8", - env: { HOME: process.env.HOME ?? "", ...env, PATH: `${bin}:/usr/bin:/bin` }, - stdio: ["ignore", "pipe", "pipe"], - }); - } catch (error) { - status = (error as { status?: number }).status ?? -1; - stdout = String((error as { stdout?: string }).stdout ?? ""); - stderr = String((error as { stderr?: string }).stderr ?? ""); + env: { HOME: process.env.HOME ?? "", ...env, PATH: `${join(dir, "bin")}:/usr/bin:/bin` }, + } as const; + try { + return { + status: 0, + lines: execFileSync("bash", ["-c", SCRIPTS["lint:yaml"] ?? ""], options) + .split("\n") + .filter(Boolean), + }; + } catch (error) { + const failed = error as { status?: number; stdout?: string; stderr?: string }; + return { + status: failed.status ?? -1, + lines: `${failed.stdout ?? ""}${failed.stderr ?? ""}`.split("\n").filter(Boolean), + }; + } + } finally { + rmSync(dir, { recursive: true, force: true }); } - return { - status, - stdout, - stderr, - calls: existsSync(calls) - ? readFileSync(calls, "utf8").split("\n").filter(Boolean) - : undefined, - }; - } finally { - rmSync(dir, { recursive: true, force: true }); } -} - -describe("lint:yaml", () => { - test("the composite hands yamllint to exactly the jobs that run it", () => { - const running = jobsWhere((step) => RUNS_LINT_YAML.test(step.run ?? "")); - expect(running).toEqual(["checks.yml#check", "nightly.yml#float-canary"]); - expect(jobsWhere(setupYamllint)).toEqual(running); - // The check script is what float-canary runs; it must carry lint:yaml for the derivation above to mean anything. - expect(PACKAGE_SCRIPTS.check).toContain("bun run lint:yaml"); - }); - test.each([ - ["bun run check", true], - ["bun run lint:yaml", true], - ["bun run check:compat", false], - ["bun run lint", false], - ["bun run build:check", false], - ])("RUNS_LINT_YAML: %j -> %p", (run, expected) => { - expect(RUNS_LINT_YAML.test(run)).toBe(expected); - }); - - test("with yamllint on PATH it lints every tracked yaml file in strict mode", () => { - const run = runLintYaml({}, true); - expect(run.status).toBe(0); - expect(run.calls?.[0]).toBe("-s"); - expect(run.calls).toContain(".github/workflows/checks.yml"); - expect(run.calls).toContain("architecture.yml"); - expect(run.calls?.some((arg) => arg.endsWith(".json"))).toBe(false); - }); - - test("without yamllint a CI run fails naming the composite input (control: the skip is local-only)", () => { - const run = runLintYaml({ CI: "true" }, false); - expect(run.status).toBe(1); - expect(run.calls).toBeUndefined(); - expect(run.stdout).toBe(""); - expect(run.stderr.trim()).toBe( - 'lint:yaml: yamllint is missing on this runner; the job needs ./.github/actions/setup with yamllint: "true"', - ); - }); - - test("without yamllint a local run says it skipped and passes", () => { - const run = runLintYaml({}, false); - expect(run.status).toBe(0); - expect(run.calls).toBeUndefined(); - expect(run.stdout.trim()).toBe( - "lint:yaml: yamllint not installed, skipping (CI runs it; install with pip install yamllint or brew install yamllint)", - ); + test("with yamllint it lints strictly; without it, CI fails naming the composite input and a local run skips", () => { + expect(lintYaml({}, true)).toEqual({ status: 0, lines: ["yamllint -s"] }); + expect(lintYaml({ CI: "true" }, false)).toEqual({ + status: 1, + lines: [ + 'lint:yaml: yamllint is missing on this runner; the job needs ./.github/actions/setup with yamllint: "true"', + ], + }); + expect(lintYaml({}, false).status).toBe(0); }); }); From 7c4cb23935d6ad2f0e3bc817145cd3e9f014892b Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:44:12 -0400 Subject: [PATCH 8/9] test(docs): pin the setup composite's step shape, continue-on-error included --- test/docs/repo-owned-workflows.test.ts | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/docs/repo-owned-workflows.test.ts b/test/docs/repo-owned-workflows.test.ts index e90dbcbf..c49ddab2 100644 --- a/test/docs/repo-owned-workflows.test.ts +++ b/test/docs/repo-owned-workflows.test.ts @@ -15,6 +15,7 @@ import { isManaged, type Job, ROOT, + readAction, readWorkflow, repoOwnedWorkflowFiles, SETUP_USES, @@ -191,6 +192,50 @@ describe("the commit-back push jobs", () => { }); }); +/** The setup composite's steps as the runner acts on them: what runs, under which gate, and whether a failure counts. */ +const SETUP_SHAPE = [ + { + uses: expect.stringMatching(/^oven-sh\/setup-bun@[0-9a-f]{40}$/), + with: { "bun-version-file": ".bun-version" }, + }, + { + if: "inputs.install != 'false'", + shell: "bash", + run: "bun install --frozen-lockfile --ignore-scripts", + }, + { if: "inputs.yamllint == 'true'", shell: "bash", run: "pipx install yamllint==1.38.0" }, +].map((step) => ({ + uses: undefined, + with: undefined, + if: undefined, + shell: undefined, + run: undefined, + "continue-on-error": undefined, + ...step, +})); + +describe("the setup composite", () => { + const shapeOf = (steps: Step[]) => + steps.map(({ uses, with: inputs, if: gate, shell, run, "continue-on-error": masked }) => ({ + uses, + with: inputs, + if: gate, + shell, + run, + "continue-on-error": masked, + })); + + test("runs the pinned bun, the gated install, and the gated yamllint, none allowed to fail", () => { + const steps = readAction(".github/actions/setup").runs.steps ?? []; + expect(shapeOf(steps)).toEqual(SETUP_SHAPE); + // Control: a masked install inside the composite, which no caller-side pin can see, fails the shape. + const masked = steps.map((step) => + step.run?.startsWith("bun install") ? { ...step, "continue-on-error": true } : step, + ); + expect(shapeOf(masked)).not.toEqual(SETUP_SHAPE); + }); +}); + describe("lint:yaml", () => { test("the composite hands yamllint to exactly the jobs that run it", () => { const running = jobsWhere((step) => From 99cb081e1f0244db7b7704de2533de0416213626 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 05:15:30 -0400 Subject: [PATCH 9/9] refactor(ci): cut the workflow pins to their invariants and the setup composite to its contract --- .github/actions/setup/action.yml | 16 +-- .github/workflows/auto-format.yml | 20 +--- test/docs/repo-owned-workflows.test.ts | 157 +++++-------------------- test/docs/workflow-loader.ts | 43 +------ 4 files changed, 39 insertions(+), 197 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index d56082e9..9a71107b 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -1,14 +1,8 @@ -# The bun setup and the dependency install of every repo-owned workflow job (checks.yml, post-green.yml, the release -# hooks, the nightlies, auto-fix, auto-format, copilot-setup-steps). The caller keeps its own checkout (ref, depth, -# and token differ per job) and its own setup-node where a step needs node. -# bun -> the version in .bun-version, never latest -# install -> bun install --frozen-lockfile --ignore-scripts: scripts.prepare installs lefthook, a git hook -# no runner uses, and no dependency here needs a lifecycle script (the whole gate runs without them) -# install: "false" -> bun alone, for a job that runs a bare .github/scripts entry or resolves the lockfile itself; -# any other value installs (GitHub compares without regard to letter case), so a typo can only -# add an install, never lose one -# yamllint: "true" -> the pinned yamllint behind `bun run lint:yaml`, which fails a CI run where it is missing -# Timeouts: every job of a repo-owned workflow carries timeout-minutes: 15 unless its comment says why it needs more. +# The bun setup and the dependency install of every repo-owned workflow job; the caller keeps its own checkout and +# setup-node, whose options differ per job. +# install -> --ignore-scripts: scripts.prepare installs lefthook, a git hook no runner uses; "false" (any letter +# case, as GitHub compares) skips the install for a job that runs a bare script or resolves the lockfile itself +# yamllint -> "true" installs the pinned yamllint behind `bun run lint:yaml`, which fails a CI run where it is missing name: Set up bun description: The pinned bun, the locked dependencies unless told not to, and yamllint for the jobs that lint YAML diff --git a/.github/workflows/auto-format.yml b/.github/workflows/auto-format.yml index 8698d436..f3cb5857 100644 --- a/.github/workflows/auto-format.yml +++ b/.github/workflows/auto-format.yml @@ -45,7 +45,7 @@ jobs: echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" bun run lint:fix # Anything the formatter left staged is not this workflow's fix: start from an empty index, then stage - # modifications of tracked files alone (a formatter adds, deletes, and renames nothing). + # modifications and deletions of tracked files alone (a formatter adds nothing). git reset -q git add -u git diff --cached --binary > "$RUNNER_TEMP/format.patch" @@ -99,24 +99,6 @@ jobs: exit 0 fi git apply --index --binary "$RUNNER_TEMP/format/format.patch" - # The patch was cut on a runner PR code ran on, so it is untrusted: a formatting patch modifies tracked - # files and never touches a workflow; anything else staged is refused. --no-renames lists a rename as its - # delete and add halves, so neither can hide behind an allowed modification. - while IFS= read -r -d '' status && IFS= read -r -d '' path; do - case "$status" in - M) ;; - *) - echo "::error::the format patch stages a '$status' for '$path'; a formatting patch modifies tracked files only; refusing to push" - exit 1 - ;; - esac - case "$path" in - .github/workflows/*) - echo "::error::the format patch touches '$path'; workflows are never formatted here; refusing to push" - exit 1 - ;; - esac - done < <(git diff --cached --no-renames --name-status -z) git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git commit --no-verify -m "style: apply automated formatting" diff --git a/test/docs/repo-owned-workflows.test.ts b/test/docs/repo-owned-workflows.test.ts index c49ddab2..267ba8bd 100644 --- a/test/docs/repo-owned-workflows.test.ts +++ b/test/docs/repo-owned-workflows.test.ts @@ -7,12 +7,10 @@ import { describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { isAlias, isMap, isScalar, parseDocument, visit } from "yaml"; import { - isManaged, type Job, ROOT, readAction, @@ -21,8 +19,6 @@ import { SETUP_USES, type Step, setupYamllint, - workflowFiles, - workflowText, } from "./workflow-loader.js"; const SCRIPTS = ( @@ -62,14 +58,6 @@ const jobsWhere = (test: (step: Step) => boolean) => repoOwnedJobs().flatMap(([where, job]) => ((job.steps ?? []).some(test) ? [where] : [])); describe("the repo-owned workflows", () => { - test("the managed set is exactly the files whose header says so", () => { - expect(workflowFiles().filter((file) => isManaged(workflowText(file)))).toEqual([ - "auto-assign.yml", - "ci.yml", - "pr-title.yml", - ]); - }); - test("every job sets bun up through the composite once, or runs no bun; every job carries a timeout", () => { for (const [where, job] of repoOwnedJobs()) { expect(setupProblem(job.steps ?? []), where).toBeUndefined(); @@ -84,6 +72,13 @@ describe("the repo-owned workflows", () => { ]; for (const steps of drifts) expect(setupProblem(steps), JSON.stringify(steps)).toBeDefined(); }); + + test("no step inside the setup composite may mask its failure (a job relies on each one it asks for)", () => { + const steps = readAction(".github/actions/setup").runs.steps ?? []; + expect(steps.length).toBeGreaterThan(1); + for (const step of steps) + expect(step["continue-on-error"], step.run ?? step.uses).toBeUndefined(); + }); }); /** Every `uses:` value in a YAML file with the comment on its line: block or flow style, an alias resolved. */ @@ -153,87 +148,21 @@ describe("action pins across the repo-owned files", () => { }); }); -/** The commit-back push jobs: [file, push step name, where HEAD_SHA comes from]. */ -const PUSH_JOBS: ReadonlyArray<[string, string, string]> = [ - ["auto-fix.yml", "Commit and push the fix", `\${{ github.event.pull_request.head.sha }}`], - ["auto-format.yml", "Commit and push the formatting", `\${{ needs.format.outputs.head }}`], -]; -const HEAD_MOVED_GUARD = - /^if \[ "\$\(git rev-parse HEAD\)" != "\$HEAD_SHA" \]; then\n {2}echo "::notice::head moved .*"\n {2}exit 0\nfi$/m; -const LEASED_PUSH = /git push --force-with-lease="refs\/heads\/\$\{HEAD_REF\}:\$\{HEAD_SHA\}"/; -/** auto-format's staged-path check between the apply and the commit: an untrusted patch may modify tracked files only. */ -const PATH_CHECK = - /^# The patch was cut on a runner PR code ran on[\s\S]*?^done < <\(git diff --cached --no-renames --name-status -z\)$/m; - describe("the commit-back push jobs", () => { - test.each(PUSH_JOBS)( - "%s: no PR code under the write token, skips on a moved head, leases the push", - (file, name, sha) => { + test.each([ + ["auto-fix.yml", "Commit and push the fix"], + ["auto-format.yml", "Commit and push the formatting"], + ])( + "%s: no PR code runs under the write token, and the push is leased to the patched head", + (file, name) => { const push = readWorkflow(file).jobs.push; expect(push?.permissions?.contents).toBe("write"); expect((push?.steps ?? []).filter(needsBun)).toEqual([]); const step = push?.steps?.find((candidate) => candidate.name === name); - expect(step?.env?.HEAD_SHA).toBe(sha); - const run = step?.run ?? ""; - expect(run).toMatch(HEAD_MOVED_GUARD); - expect(run).toMatch(LEASED_PUSH); - expect(run.search(HEAD_MOVED_GUARD)).toBeLessThan(run.indexOf("git apply")); + expect(step?.env?.HEAD_SHA).toBeDefined(); + expect(step?.run).toContain(`--force-with-lease="refs/heads/\${HEAD_REF}:\${HEAD_SHA}"`); }, ); - - test("auto-format checks the staged paths after the apply and before the commit", () => { - const push = readWorkflow("auto-format.yml").jobs.push; - const run = - push?.steps?.find((step) => step.name === "Commit and push the formatting")?.run ?? ""; - expect(run.search(PATH_CHECK)).toBeGreaterThan(run.indexOf("git apply")); - expect(run.search(PATH_CHECK)).toBeLessThan(run.indexOf("git commit")); - expect(run).toContain("M) ;;"); - expect(run).toContain(".github/workflows/*)"); - }); -}); - -/** The setup composite's steps as the runner acts on them: what runs, under which gate, and whether a failure counts. */ -const SETUP_SHAPE = [ - { - uses: expect.stringMatching(/^oven-sh\/setup-bun@[0-9a-f]{40}$/), - with: { "bun-version-file": ".bun-version" }, - }, - { - if: "inputs.install != 'false'", - shell: "bash", - run: "bun install --frozen-lockfile --ignore-scripts", - }, - { if: "inputs.yamllint == 'true'", shell: "bash", run: "pipx install yamllint==1.38.0" }, -].map((step) => ({ - uses: undefined, - with: undefined, - if: undefined, - shell: undefined, - run: undefined, - "continue-on-error": undefined, - ...step, -})); - -describe("the setup composite", () => { - const shapeOf = (steps: Step[]) => - steps.map(({ uses, with: inputs, if: gate, shell, run, "continue-on-error": masked }) => ({ - uses, - with: inputs, - if: gate, - shell, - run, - "continue-on-error": masked, - })); - - test("runs the pinned bun, the gated install, and the gated yamllint, none allowed to fail", () => { - const steps = readAction(".github/actions/setup").runs.steps ?? []; - expect(shapeOf(steps)).toEqual(SETUP_SHAPE); - // Control: a masked install inside the composite, which no caller-side pin can see, fails the shape. - const masked = steps.map((step) => - step.run?.startsWith("bun install") ? { ...step, "continue-on-error": true } : step, - ); - expect(shapeOf(masked)).not.toEqual(SETUP_SHAPE); - }); }); describe("lint:yaml", () => { @@ -241,55 +170,27 @@ describe("lint:yaml", () => { const running = jobsWhere((step) => /\bbun run (?:check|lint:yaml)(?![\w:.-])/.test(step.run ?? ""), ); - expect(running).toEqual(["checks.yml#check", "nightly.yml#float-canary"]); + expect(running.length).toBeGreaterThan(0); expect(jobsWhere(setupYamllint)).toEqual(running); expect(SCRIPTS.check).toContain("bun run lint:yaml"); }); - /** The script from the repository root with PATH cut to the system directories plus a stub bin (a yamllint that reports its call, or nothing). */ - function lintYaml( - env: Record, - stub: boolean, - ): { status: number; lines: string[] } { - const dir = mkdtempSync(join(tmpdir(), "lint-yaml-")); + /** The script's exit status from the repository root with PATH cut to the system directories, where yamllint is absent. */ + function lintYamlWithout(env: Record): number { try { - mkdirSync(join(dir, "bin")); - if (stub) - writeFileSync(join(dir, "bin/yamllint"), '#!/bin/sh\necho "yamllint $1"\n', { - mode: 0o755, - }); - const options = { + execFileSync("bash", ["-c", SCRIPTS["lint:yaml"] ?? ""], { cwd: ROOT, - encoding: "utf8", - env: { HOME: process.env.HOME ?? "", ...env, PATH: `${join(dir, "bin")}:/usr/bin:/bin` }, - } as const; - try { - return { - status: 0, - lines: execFileSync("bash", ["-c", SCRIPTS["lint:yaml"] ?? ""], options) - .split("\n") - .filter(Boolean), - }; - } catch (error) { - const failed = error as { status?: number; stdout?: string; stderr?: string }; - return { - status: failed.status ?? -1, - lines: `${failed.stdout ?? ""}${failed.stderr ?? ""}`.split("\n").filter(Boolean), - }; - } - } finally { - rmSync(dir, { recursive: true, force: true }); + stdio: "pipe", + env: { HOME: process.env.HOME ?? "", ...env, PATH: "/usr/bin:/bin" }, + }); + return 0; + } catch (error) { + return (error as { status?: number }).status ?? -1; } } - test("with yamllint it lints strictly; without it, CI fails naming the composite input and a local run skips", () => { - expect(lintYaml({}, true)).toEqual({ status: 0, lines: ["yamllint -s"] }); - expect(lintYaml({ CI: "true" }, false)).toEqual({ - status: 1, - lines: [ - 'lint:yaml: yamllint is missing on this runner; the job needs ./.github/actions/setup with yamllint: "true"', - ], - }); - expect(lintYaml({}, false).status).toBe(0); + test("without yamllint a CI run fails and a local run skips", () => { + expect(lintYamlWithout({ CI: "true" })).toBe(1); + expect(lintYamlWithout({})).toBe(0); }); }); diff --git a/test/docs/workflow-loader.ts b/test/docs/workflow-loader.ts index 63f0a907..1d299903 100644 --- a/test/docs/workflow-loader.ts +++ b/test/docs/workflow-loader.ts @@ -11,8 +11,8 @@ export const ROOT = join(import.meta.dir, "..", ".."); const WORKFLOWS_DIR = join(ROOT, ".github", "workflows"); /** The setup composite (bun, the locked install, yamllint on request), as a job's `uses:` spells it. */ export const SETUP_USES = "./.github/actions/setup"; -/** The header line of a file the platform sync overwrites; every other workflow is repo-owned. */ -const MANAGED_HEADER = "managed by Vivswan/repo-platform"; +/** A leading comment block naming the platform sync: such a file is overwritten on every sync, every other is repo-owned. */ +const MANAGED = /^(?:\s*#.*\n)*?\s*#.*managed by Vivswan\/repo-platform/; export interface Step { name?: string; @@ -38,40 +38,23 @@ export interface Job { "timeout-minutes"?: number; permissions?: Record; concurrency?: Concurrency; - strategy?: unknown; - outputs?: Record; steps?: Step[]; uses?: string; with?: Record; secrets?: unknown; } -interface TriggerInput { - description?: string; - required?: boolean; - type?: string; - default?: unknown; -} interface Trigger { - inputs?: Record; + inputs?: Record; secrets?: Record; [key: string]: unknown; } export interface Workflow { - name?: string; on: Record; permissions?: Record; concurrency?: Concurrency; jobs: Record; } -interface ActionInput { - description?: string; - required?: boolean; - default?: string; -} export interface CompositeAction { - name?: string; - description?: string; - inputs?: Record; runs: { using?: string; steps?: Step[] }; } @@ -88,31 +71,14 @@ export function readAction(dir: string): CompositeAction { return parseYaml(readFileSync(join(ROOT, dir, "action.yml"), "utf8")) as CompositeAction; } -/** Every workflow file, sorted. */ export function workflowFiles(): string[] { return readdirSync(WORKFLOWS_DIR) .filter((file) => /\.ya?ml$/.test(file)) .sort(); } -/** True when the file's leading comment block carries the managed-by header. */ -export function isManaged(text: string): boolean { - for (const line of text.split("\n")) { - if (line.trim() === "") { - continue; - } - if (!line.startsWith("#")) { - return false; - } - if (line.includes(MANAGED_HEADER)) { - return true; - } - } - return false; -} - export function repoOwnedWorkflowFiles(): string[] { - return workflowFiles().filter((file) => !isManaged(workflowText(file))); + return workflowFiles().filter((file) => !MANAGED.test(workflowText(file))); } /** The run scalar's lines with every heredoc body (`<