diff --git a/.editorconfig b/.editorconfig index 7e5c5b76..ee8a2b72 100644 --- a/.editorconfig +++ b/.editorconfig @@ -37,6 +37,11 @@ indent_size = 2 [*.{yml,yaml}] indent_size = 2 +# Workflow YAML is LF: Dependabot and Actions rewrite it with LF, so declaring LF keeps it consistent instead of +# mixed. git still leaves endings alone (`* -text`); this and CI (editorconfig-checker) enforce it. Other YAML is CRLF. +[.github/workflows/*.{yml,yaml}] +end_of_line = lf + # Linux scripts [*.sh] end_of_line = lf diff --git a/.editorconfig-checker.json b/.editorconfig-checker.json new file mode 100644 index 00000000..e019960b --- /dev/null +++ b/.editorconfig-checker.json @@ -0,0 +1,10 @@ +{ + "Disable": { + "Charset": true, + "Indentation": true, + "IndentSize": true, + "TrimTrailingWhitespace": true, + "InsertFinalNewline": true, + "MaxLineLength": true + } +} diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index f09e3049..60d85d9c 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -1,184 +1,184 @@ -name: Merge bot pull request action - -# Enable auto-merge once per PR on opened/reopened; disable it when a maintainer pushes to a bot branch. Merge -# method by base branch (develop = squash, main = merge). App token so the merge fires downstream workflows -# (GITHUB_TOKEN pushes don't) and so the disable job has write access on read-only Dependabot PRs. - -# `pull_request_target` (not `pull_request`): these jobs hold the App private key, so the workflow definition and -# its action SHAs must resolve from the trusted base branch, not the PR head. Safe because no job checks out PR -# code - each only runs `gh pr merge` against the PR by URL. -on: - pull_request_target: - types: [opened, reopened, synchronize] - -# Per-PR group: under `pull_request_target` `github.ref` is the base branch, which would serialize every bot PR -# against that base; key on the PR number so each PR's events queue independently. `cancel-in-progress: false` so a -# follow-up synchronize doesn't cancel an in-flight `opened` run before it enables auto-merge. -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: false - -jobs: - - merge-dependabot: - name: Merge dependabot pull request job - runs-on: ubuntu-latest - # Dependabot PRs from this repo (not forks). Only on opened/reopened so the disable job stays sticky. - if: >- - (github.event.action == 'opened' || github.event.action == 'reopened') && - github.event.pull_request.user.login == 'dependabot[bot]' && - github.event.pull_request.head.repo.full_name == github.repository - permissions: - contents: write - pull-requests: write - - steps: - - - name: Generate GitHub App token step - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} - private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - - - name: Get dependabot metadata step - id: metadata - uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 - with: - github-token: "${{ secrets.GITHUB_TOKEN }}" - - # Skip semver-major NuGet bumps so they land via human review; other ecosystems' majors auto-merge. - - name: Merge pull request step - if: >- - (steps.metadata.outputs.package-ecosystem != 'nuget') || - (steps.metadata.outputs.update-type != 'version-update:semver-major') - run: | - set -euo pipefail - case "${{ github.event.pull_request.base.ref }}" in - develop) method=--squash ;; - main) method=--merge ;; - *) - echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" - exit 1 - ;; - esac - gh pr merge --auto "$method" "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} - - merge-codegen: - name: Merge codegen pull request job - runs-on: ubuntu-latest - # Codegen PRs from this repo. Head/base pairing is enforced strictly (codegen-main->main, codegen-develop-> - # develop). Only on opened/reopened so the disable job stays sticky. - if: >- - (github.event.action == 'opened' || github.event.action == 'reopened') && - github.event.pull_request.user.login == 'ptr727-codegen[bot]' && - github.event.pull_request.head.repo.full_name == github.repository && - ( - (github.event.pull_request.head.ref == 'codegen-main' && github.event.pull_request.base.ref == 'main') || - (github.event.pull_request.head.ref == 'codegen-develop' && github.event.pull_request.base.ref == 'develop') - ) - permissions: - contents: write - pull-requests: write - - steps: - - - name: Generate GitHub App token step - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} - private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - - - name: Merge pull request step - run: | - set -euo pipefail - case "${{ github.event.pull_request.base.ref }}" in - develop) method=--squash ;; - main) method=--merge ;; - *) - echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" - exit 1 - ;; - esac - gh pr merge --auto "$method" "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} - - merge-upstream-version: - name: Merge upstream version pull request job - runs-on: ubuntu-latest - # Upstream-version bump PRs from the App. Head/base pairing is enforced (upstream-version-main->main, - # upstream-version-develop->develop). Only on opened/reopened so the disable job stays sticky. - if: >- - (github.event.action == 'opened' || github.event.action == 'reopened') && - github.event.pull_request.user.login == 'ptr727-codegen[bot]' && - github.event.pull_request.head.repo.full_name == github.repository && - ( - (github.event.pull_request.head.ref == 'upstream-version-main' && github.event.pull_request.base.ref == 'main') || - (github.event.pull_request.head.ref == 'upstream-version-develop' && github.event.pull_request.base.ref == 'develop') - ) - permissions: - contents: write - pull-requests: write - - steps: - - - name: Generate GitHub App token step - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} - private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - - - name: Merge pull request step - run: | - set -euo pipefail - case "${{ github.event.pull_request.base.ref }}" in - develop) method=--squash ;; - main) method=--merge ;; - *) - echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" - exit 1 - ;; - esac - gh pr merge --auto "$method" "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} - - disable-auto-merge-on-maintainer-push: - name: Disable auto-merge on maintainer push job - runs-on: ubuntu-latest - # Fires when a maintainer pushes to a bot's branch (synchronize, actor != bot). Disables auto-merge so the - # maintainer's commits don't merge with the bot's; they re-enable it manually. The disable call is idempotent. - if: >- - github.event.action == 'synchronize' && - github.event.pull_request.head.repo.full_name == github.repository && - ( - github.event.pull_request.user.login == 'dependabot[bot]' || - github.event.pull_request.user.login == 'ptr727-codegen[bot]' - ) && - github.actor != github.event.pull_request.user.login - permissions: - pull-requests: write - - steps: - - - name: Generate GitHub App token step - # App token because a Dependabot PR's GITHUB_TOKEN is read-only regardless of who triggered the event. - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} - private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - - - name: Disable auto-merge step - run: gh pr merge --disable-auto "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} +name: Merge bot pull request action + +# Enable auto-merge once per PR on opened/reopened; disable it when a maintainer pushes to a bot branch. Merge +# method by base branch (develop = squash, main = merge). App token so the merge fires downstream workflows +# (GITHUB_TOKEN pushes don't) and so the disable job has write access on read-only Dependabot PRs. + +# `pull_request_target` (not `pull_request`): these jobs hold the App private key, so the workflow definition and +# its action SHAs must resolve from the trusted base branch, not the PR head. Safe because no job checks out PR +# code - each only runs `gh pr merge` against the PR by URL. +on: + pull_request_target: + types: [opened, reopened, synchronize] + +# Per-PR group: under `pull_request_target` `github.ref` is the base branch, which would serialize every bot PR +# against that base; key on the PR number so each PR's events queue independently. `cancel-in-progress: false` so a +# follow-up synchronize doesn't cancel an in-flight `opened` run before it enables auto-merge. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + + merge-dependabot: + name: Merge dependabot pull request job + runs-on: ubuntu-latest + # Dependabot PRs from this repo (not forks). Only on opened/reopened so the disable job stays sticky. + if: >- + (github.event.action == 'opened' || github.event.action == 'reopened') && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: write + pull-requests: write + + steps: + + - name: Generate GitHub App token step + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Get dependabot metadata step + id: metadata + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + # Skip semver-major NuGet bumps so they land via human review; other ecosystems' majors auto-merge. + - name: Merge pull request step + if: >- + (steps.metadata.outputs.package-ecosystem != 'nuget') || + (steps.metadata.outputs.update-type != 'version-update:semver-major') + run: | + set -euo pipefail + case "${{ github.event.pull_request.base.ref }}" in + develop) method=--squash ;; + main) method=--merge ;; + *) + echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" + exit 1 + ;; + esac + gh pr merge --auto "$method" "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + + merge-codegen: + name: Merge codegen pull request job + runs-on: ubuntu-latest + # Codegen PRs from this repo. Head/base pairing is enforced strictly (codegen-main->main, codegen-develop-> + # develop). Only on opened/reopened so the disable job stays sticky. + if: >- + (github.event.action == 'opened' || github.event.action == 'reopened') && + github.event.pull_request.user.login == 'ptr727-codegen[bot]' && + github.event.pull_request.head.repo.full_name == github.repository && + ( + (github.event.pull_request.head.ref == 'codegen-main' && github.event.pull_request.base.ref == 'main') || + (github.event.pull_request.head.ref == 'codegen-develop' && github.event.pull_request.base.ref == 'develop') + ) + permissions: + contents: write + pull-requests: write + + steps: + + - name: Generate GitHub App token step + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Merge pull request step + run: | + set -euo pipefail + case "${{ github.event.pull_request.base.ref }}" in + develop) method=--squash ;; + main) method=--merge ;; + *) + echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" + exit 1 + ;; + esac + gh pr merge --auto "$method" "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + + merge-upstream-version: + name: Merge upstream version pull request job + runs-on: ubuntu-latest + # Upstream-version bump PRs from the App. Head/base pairing is enforced (upstream-version-main->main, + # upstream-version-develop->develop). Only on opened/reopened so the disable job stays sticky. + if: >- + (github.event.action == 'opened' || github.event.action == 'reopened') && + github.event.pull_request.user.login == 'ptr727-codegen[bot]' && + github.event.pull_request.head.repo.full_name == github.repository && + ( + (github.event.pull_request.head.ref == 'upstream-version-main' && github.event.pull_request.base.ref == 'main') || + (github.event.pull_request.head.ref == 'upstream-version-develop' && github.event.pull_request.base.ref == 'develop') + ) + permissions: + contents: write + pull-requests: write + + steps: + + - name: Generate GitHub App token step + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Merge pull request step + run: | + set -euo pipefail + case "${{ github.event.pull_request.base.ref }}" in + develop) method=--squash ;; + main) method=--merge ;; + *) + echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" + exit 1 + ;; + esac + gh pr merge --auto "$method" "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + + disable-auto-merge-on-maintainer-push: + name: Disable auto-merge on maintainer push job + runs-on: ubuntu-latest + # Fires when a maintainer pushes to a bot's branch (synchronize, actor != bot). Disables auto-merge so the + # maintainer's commits don't merge with the bot's; they re-enable it manually. The disable call is idempotent. + if: >- + github.event.action == 'synchronize' && + github.event.pull_request.head.repo.full_name == github.repository && + ( + github.event.pull_request.user.login == 'dependabot[bot]' || + github.event.pull_request.user.login == 'ptr727-codegen[bot]' + ) && + github.actor != github.event.pull_request.user.login + permissions: + pull-requests: write + + steps: + + - name: Generate GitHub App token step + # App token because a Dependabot PR's GITHUB_TOKEN is read-only regardless of who triggered the event. + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Disable auto-merge step + run: gh pr merge --disable-auto "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 87d9b025..9f8f75d4 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -1,75 +1,75 @@ -name: Publish project release action - -on: - workflow_dispatch: - -# A publish is a deliberate dispatch, so runs serialize on one group; queue rather than cancel so a run is never -# left with a half-created GitHub release. -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -jobs: - - # Publish the dispatched branch (main => release, develop => prerelease): NBGV computes the tag from the ref, then - # a GitHub release is created (tag + auto source archive + README + LICENSE). Source-only repo - no build targets. - publish: - name: Publish project release job - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - - name: Assert dispatch ref step - run: | - set -euo pipefail - if [ "${{ github.ref_name }}" != "main" ] && [ "${{ github.ref_name }}" != "develop" ]; then - echo "::error::Dispatch publish-release from main (release) or develop (prerelease); got ${{ github.ref_name }}." - exit 1 - fi - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 - with: - dotnet-version: 10.x - - # Full history so NBGV can compute the git height for the branch. - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.ref_name }} - fetch-depth: 0 - - # NBGV versions the dispatched ref: main is the public-release ref (clean X.Y.Z), develop a prerelease. - - name: Compute version step - id: nbgv - uses: dotnet/nbgv@master - - # Skip create on an existing tag (no-op republish); a re-dispatch refreshes it. - - name: Check for existing release step - id: release-exists - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.nbgv.outputs.SemVer2 }} - run: | - set -euo pipefail - if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - # target_commitish pins the tag to the exact built commit (GitCommitId), not the default branch. The release is - # the tag plus GitHub's auto source archive, README, and LICENSE - no build assets (source-only). - - name: Create GitHub release step - if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 - with: - generate_release_notes: true - tag_name: ${{ steps.nbgv.outputs.SemVer2 }} - target_commitish: ${{ steps.nbgv.outputs.GitCommitId }} - prerelease: ${{ github.ref_name != 'main' }} - files: | - LICENSE - README.md +name: Publish project release action + +on: + workflow_dispatch: + +# A publish is a deliberate dispatch, so runs serialize on one group; queue rather than cancel so a run is never +# left with a half-created GitHub release. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + + # Publish the dispatched branch (main => release, develop => prerelease): NBGV computes the tag from the ref, then + # a GitHub release is created (tag + auto source archive + README + LICENSE). Source-only repo - no build targets. + publish: + name: Publish project release job + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + + - name: Assert dispatch ref step + run: | + set -euo pipefail + if [ "${{ github.ref_name }}" != "main" ] && [ "${{ github.ref_name }}" != "develop" ]; then + echo "::error::Dispatch publish-release from main (release) or develop (prerelease); got ${{ github.ref_name }}." + exit 1 + fi + + - name: Setup .NET SDK step + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + with: + dotnet-version: 10.x + + # Full history so NBGV can compute the git height for the branch. + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + # NBGV versions the dispatched ref: main is the public-release ref (clean X.Y.Z), develop a prerelease. + - name: Compute version step + id: nbgv + uses: dotnet/nbgv@master + + # Skip create on an existing tag (no-op republish); a re-dispatch refreshes it. + - name: Check for existing release step + id: release-exists + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.nbgv.outputs.SemVer2 }} + run: | + set -euo pipefail + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + # target_commitish pins the tag to the exact built commit (GitCommitId), not the default branch. The release is + # the tag plus GitHub's auto source archive, README, and LICENSE - no build assets (source-only). + - name: Create GitHub release step + if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + with: + generate_release_notes: true + tag_name: ${{ steps.nbgv.outputs.SemVer2 }} + target_commitish: ${{ steps.nbgv.outputs.GitCommitId }} + prerelease: ${{ github.ref_name != 'main' }} + files: | + LICENSE + README.md diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index ba6ea95c..3ed3c819 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -1,59 +1,62 @@ -name: Test pull request action - -on: - pull_request: - branches: [ main, develop ] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - - # Source-only repo: CI is lint-only. Markdown, spelling, workflow YAML, and the registry/spec JSON are validated with - # the same configs the editor extensions and CLI use (linter parity). There is no build or unit test. - lint: - name: Lint sources job - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - # Docker images are digest-pinned (version in the trailing comment) so CI is reproducible. - - name: Lint Markdown step - run: docker run --rm -v "$PWD":/workdir davidanson/markdownlint-cli2@sha256:0ed9a5f4c77ef447da2a2ac6e67caf74b214a7f80288819565e8b7d2ac148fe5 "**/*.md" # markdownlint-cli2 v0.22.1 - - - name: Spell check step - run: docker run --rm -v "$PWD":/workdir --workdir /workdir ghcr.io/streetsidesoftware/cspell@sha256:cb2eab4ec34956aca554e35615da65401d510fa5983cce2773391e2fd9f4fc20 "**/*.md" # cspell v10.0.1 - - - name: Lint workflows step - run: docker run --rm -v "$PWD":/repo --workdir /repo rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 -color # actionlint v1.7.12 - - - name: Validate registry and spec step - run: | - set -euo pipefail - for f in registry/*.json spec/*.json repo-config/*.json; do - jq empty "$f" - done - python3 spec/validate.py - - # GitHub Actions does not support required status checks on conditional jobs, so a single always-run aggregator gates - # the merge. Its name is the ruleset-bound required status-check context - rename it and the ruleset context together. - check-workflow-status: - name: Check pull request workflow status job - runs-on: ubuntu-latest - needs: [ lint ] - if: always() - steps: - - name: Check workflow results step - run: | - set -euo pipefail - if [[ "${{ needs.lint.result }}" != "success" ]]; then - echo "Job 'lint' did not succeed (${{ needs.lint.result }}); refusing to pass." - exit 1 - fi +name: Test pull request action + +on: + pull_request: + branches: [ main, develop ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + + # Source-only repo: CI is lint-only. Markdown, spelling, workflow YAML, and the registry/spec JSON are validated with + # the same configs the editor extensions and CLI use (linter parity). There is no build or unit test. + lint: + name: Lint sources job + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # Docker images are digest-pinned (version in the trailing comment) so CI is reproducible. + - name: Lint Markdown step + run: docker run --rm -v "$PWD":/workdir davidanson/markdownlint-cli2@sha256:0ed9a5f4c77ef447da2a2ac6e67caf74b214a7f80288819565e8b7d2ac148fe5 "**/*.md" # markdownlint-cli2 v0.22.1 + + - name: Spell check step + run: docker run --rm -v "$PWD":/workdir --workdir /workdir ghcr.io/streetsidesoftware/cspell@sha256:cb2eab4ec34956aca554e35615da65401d510fa5983cce2773391e2fd9f4fc20 "**/*.md" # cspell v10.0.1 + + - name: Lint workflows step + run: docker run --rm -v "$PWD":/repo --workdir /repo rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 -color # actionlint v1.7.12 + + - name: Check line endings step + run: docker run --rm -v "$PWD":/check --workdir /check mstruebing/editorconfig-checker@sha256:67b9e9b16a674e36f7c05919da789f03a01d343ca8423eb8797179399af07c00 # editorconfig-checker v3.4.0 + + - name: Validate registry and spec step + run: | + set -euo pipefail + for f in registry/*.json spec/*.json repo-config/*.json; do + jq empty "$f" + done + python3 spec/validate.py + + # GitHub Actions does not support required status checks on conditional jobs, so a single always-run aggregator gates + # the merge. Its name is the ruleset-bound required status-check context - rename it and the ruleset context together. + check-workflow-status: + name: Check pull request workflow status job + runs-on: ubuntu-latest + needs: [ lint ] + if: always() + steps: + - name: Check workflow results step + run: | + set -euo pipefail + if [[ "${{ needs.lint.result }}" != "success" ]]; then + echo "Job 'lint' did not succeed (${{ needs.lint.result }}); refusing to pass." + exit 1 + fi diff --git a/AGENTS.md b/AGENTS.md index aedf85f5..a95c603b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,7 +124,7 @@ Applies to code and workflow (`#`) comments alike. ### Line Endings - **[`.editorconfig`](./.editorconfig) sets the line ending:** `[*] end_of_line = crlf` is the **default** - every file type is CRLF unless pinned otherwise - with **LF** pinned for the execution-sensitive exceptions - `*.sh`, Dockerfiles, and any individual `.py` executed directly via its shebang (pinned **by path**, e.g. `spec/validate.py`; vanilla `.py` stays CRLF, since Python's universal newlines accept it and it is commonly edited on Windows). Only the LF exceptions are declared; the redundant per-type CRLF rules are intentionally omitted. `.gitattributes` mirrors it: `* -text` (git stores the exact bytes you commit and will **not** normalize) plus the matching LF pins. -- **Choosing an ending for a new file type:** CRLF is the **default** - cross-platform editors on Windows produce it, and it is harmless on Linux for everything except shell. Use LF only when the type **requires** it or CRLF **breaks how it is consumed**: executable scripts/shebangs (`*.sh`, s6, husky), Dockerfiles (CRLF breaks `RUN` heredocs/continuations), and tool-owned formats with a native LF ending (KiCad). **YAML stays CRLF** - GitHub Actions' parser tolerates it and these repos run it without breakage (a repo that also runs yamllint sets `new-lines: disable` to defer to `.editorconfig`). Distinguish where a file is *consumed* from where it is *edited*: consumption on Linux alone does not force LF. +- **Choosing an ending for a new file type:** CRLF is the **default** - cross-platform editors on Windows produce it, and it is harmless on Linux for everything except shell. Use LF only when the type **requires** it or CRLF **breaks how it is consumed**: executable scripts/shebangs (`*.sh`, s6, husky), Dockerfiles (CRLF breaks `RUN` heredocs/continuations), and tool-owned formats with a native LF ending (KiCad). **Non-workflow YAML stays CRLF** - GitHub Actions' parser tolerates it (a repo that also runs yamllint sets `new-lines: disable` to defer to `.editorconfig`). **Workflow YAML (`.github/workflows/*.{yml,yaml}`) is pinned LF** in `.editorconfig` - Dependabot and Actions rewrite it with LF, so declaring LF keeps it consistent instead of mixed on every bump. This is the one LF class that is **not** also a `.gitattributes` pin: git keeps `* -text` (no normalization), and CI's `editorconfig-checker` (EOL-only) catches a mismatch instead. Distinguish where a file is *consumed* from where it is *edited*: consumption on Linux alone does not force LF. - **Scripts and extensionless executables must be LF - and pinned in `.gitattributes`, not just configured.** A CRLF shebang (`#!/usr/bin/env bash\r`) breaks execution. `.editorconfig` sets `[*.sh] = lf`, but that extension-based rule does not match **extensionless** executables (s6 service scripts `run`/`up`/`finish`, husky/git hook scripts like `.husky/pre-commit`), and `* -text` enforces nothing - so a broad normalization pass or an editor can silently flip them to CRLF (it has). `.gitattributes` is the enforcement layer: it carries `*.sh text eol=lf`, and any repo whose tooling ships extensionless scripts **adds the matching path pin** - e.g. `Docker/s6-overlay/** text eol=lf` for s6 init, `.husky/pre-commit text eol=lf` for husky hooks - so git holds them at LF on checkout and `--renormalize`. This pin is mandatory for any repo that overrides s6 init, uses husky/git hooks, or otherwise ships executable scripts. The same explicit-pin rule extends to **tool-owned file formats the base config doesn't key on**: pin them to whatever ending the tool reads and writes so a normalization sweep can't churn them - e.g. KiCad project/footprint/3D files (`*.kicad_mod`, `*.kicad_sym`, `*.step`), which KiCad writes LF (`*.kicad_mod text eol=lf`, ...). The principle is general: a file class the `.editorconfig` extension rules and `* -text` don't cover needs an explicit `.gitattributes` pin matching its tool's native ending. - **Pair each such pin with a matching `.editorconfig` override - the git pin alone is not enough.** `.gitattributes` governs **git** (checkout, commit, `--renormalize`); the **editor** follows `.editorconfig`, where the `[*] end_of_line = crlf` default still applies to any file no extension rule covers. So even with the git pin, the editor writes a CRLF shebang into an extensionless hook (breaking it when run from the working tree) or re-ends/trims a byte-sensitive data file. Give every extensionless **executable** an editorconfig LF override beside its `.gitattributes` pin (`[.husky/pre-commit] end_of_line = lf`); and for a **byte-preserve data directory** (downloaded or opaque source whose exact bytes the consumer may depend on) disable *all* editor normalization, not just EOL - `[