From 16bcba4422b7d4eb73104d48a35d8cae3c67621d Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:54:36 -0400 Subject: [PATCH] feat!: convert reusable workflows to composite actions A reusable workflow cannot run a runner script that lives in this private repo when called from another private repo: the caller's GITHUB_TOKEN is repo-scoped, so a second actions/checkout of ci-workflows fails. Composite actions are provisioned by the Actions service via a scoped, read-only, one-hour installation token (distinct from GITHUB_TOKEN), so they work cross-repo PAT-free and their bundled files are reachable via $GITHUB_ACTION_PATH with no checkout. Replace .github/workflows/markdown.yml and powershell.yml with composite actions at .github/actions/{markdown,powershell}; bundle Invoke-Pssa.ps1 alongside the powershell action. Rewrite the dogfood ci.yml to checkout + local action refs behind the ci-status gateway. Extend Dependabot to scan the composite actions' own action.yml files (a "/" target does not descend into .github/actions//). BREAKING CHANGE: consumers now reference melodic-software/ci-workflows/.github/actions/@ from a job step (after actions/checkout), not the old reusable-workflow job-level uses:. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/markdown/action.yml | 34 ++++++++ .../actions/powershell}/Invoke-Pssa.ps1 | 0 .github/actions/powershell/action.yml | 57 +++++++++++++ .github/dependabot.yml | 11 ++- .github/workflows/ci.yml | 21 +++-- .github/workflows/markdown.yml | 44 ---------- .github/workflows/powershell.yml | 80 ------------------- README.md | 60 ++++++++------ 8 files changed, 149 insertions(+), 158 deletions(-) create mode 100644 .github/actions/markdown/action.yml rename {scripts => .github/actions/powershell}/Invoke-Pssa.ps1 (100%) create mode 100644 .github/actions/powershell/action.yml delete mode 100644 .github/workflows/markdown.yml delete mode 100644 .github/workflows/powershell.yml diff --git a/.github/actions/markdown/action.yml b/.github/actions/markdown/action.yml new file mode 100644 index 0000000..e4cf192 --- /dev/null +++ b/.github/actions/markdown/action.yml @@ -0,0 +1,34 @@ +name: markdown +description: Lint Markdown with markdownlint-cli2 against a caller-supplied config. + +inputs: + globs: + description: >- + markdownlint-cli2 glob arguments, space-separated (word-split on the + runner). Prefix a glob with '!' to exclude. Default lints all markdown. + default: '**/*.md' + config: + description: Path to the markdownlint-cli2 config file in the caller repo. + default: modules/markdown/.markdownlint-cli2.jsonc + version: + description: Exact markdownlint-cli2 version to run. + default: 0.22.1 + node-version: + description: Node.js version to set up. + default: 24.17.0 + +runs: + using: composite + steps: + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ inputs.node-version }} + - name: Lint markdown + shell: bash + env: + CONFIG: ${{ inputs.config }} + VERSION: ${{ inputs.version }} + GLOBS: ${{ inputs.globs }} + # $GLOBS is intentionally unquoted so multiple glob arguments word-split. + run: npx --yes "markdownlint-cli2@$VERSION" --config "$CONFIG" $GLOBS diff --git a/scripts/Invoke-Pssa.ps1 b/.github/actions/powershell/Invoke-Pssa.ps1 similarity index 100% rename from scripts/Invoke-Pssa.ps1 rename to .github/actions/powershell/Invoke-Pssa.ps1 diff --git a/.github/actions/powershell/action.yml b/.github/actions/powershell/action.yml new file mode 100644 index 0000000..944b63b --- /dev/null +++ b/.github/actions/powershell/action.yml @@ -0,0 +1,57 @@ +name: powershell +description: Analyze PowerShell with PSScriptAnalyzer via the bundled per-file runner. + +inputs: + paths: + description: >- + Files/dirs to analyze (relative to the caller repo), space-separated. + Directories recurse. Default analyzes the whole repo. + default: '.' + settings: + description: Path to the PSScriptAnalyzerSettings.psd1 ruleset in the caller repo. + default: modules/powershell/PSScriptAnalyzerSettings.psd1 + exclude: + description: >- + Path substrings to skip, space-separated (e.g. intentionally-bad + fixtures). The .git directory is always skipped. + default: '' + analyzer-version: + description: Exact PSScriptAnalyzer version to install and require. + default: 1.25.0 + +runs: + using: composite + steps: + - name: Ensure PSScriptAnalyzer + shell: pwsh + env: + REQUIRED: ${{ inputs.analyzer-version }} + run: | + $required = $env:REQUIRED + $have = Get-Module -ListAvailable PSScriptAnalyzer | + Where-Object { $_.Version -eq [version]$required } + if (-not $have) { + try { + Install-Module PSScriptAnalyzer -RequiredVersion $required -Scope CurrentUser -Force -SkipPublisherCheck -ErrorAction Stop + } catch { + Install-PSResource PSScriptAnalyzer -Version $required -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop + } + } + Import-Module PSScriptAnalyzer -RequiredVersion $required -ErrorAction Stop + Write-Output "PSScriptAnalyzer $((Get-Module PSScriptAnalyzer).Version) on pwsh $($PSVersionTable.PSVersion)" + + - name: Analyze + shell: pwsh + env: + PATHS: ${{ inputs.paths }} + SETTINGS: ${{ inputs.settings }} + EXCLUDE: ${{ inputs.exclude }} + REQUIRED: ${{ inputs.analyzer-version }} + ACTION_PATH: ${{ github.action_path }} + run: | + # Wrap in @() so an empty input stays an empty array, never $null + # (a bare null would violate the runner's non-null parameter contract). + $paths = @($env:PATHS -split '\s+' | Where-Object { $_ }) + $exclude = @($env:EXCLUDE -split '\s+' | Where-Object { $_ }) + & "$env:ACTION_PATH/Invoke-Pssa.ps1" -Path $paths -Settings $env:SETTINGS -AnalyzerVersion $env:REQUIRED -ExcludePath $exclude + exit $LASTEXITCODE diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6e45358..35203cf 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,13 @@ version: 2 updates: - # Keep the actions and reusable workflows referenced by SHA current. Review - # every PR before merge; never auto-merge (a SHA bump is executable CI logic). + # Keep SHA-pinned actions current. Review every PR before merge; never + # auto-merge (a SHA bump is executable CI logic). The "/" target scans + # .github/workflows; the glob target is required to also scan the composite + # actions' own action.yml files (Dependabot does not descend into + # .github/actions// from "/" alone). - package-ecosystem: github-actions - directory: / + directories: + - / + - /.github/actions/* schedule: interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ec6cf5..9297a43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,15 +7,26 @@ on: permissions: contents: read -# Dogfood: this repo is the first consumer of its own reusable workflows. Each -# lane runs against this repo's vendored config copy; the local ci-status -# gateway aggregates them into the single required check. +# Dogfood: this repo is the first consumer of its own composite actions. Each +# lane checks out this repo and runs a lane action against the vendored config +# copy; the local ci-status gateway aggregates them into the single required +# check. Local (./) action refs work here because the checkout IS this repo. jobs: markdown: - uses: ./.github/workflows/markdown.yml + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Lint markdown + uses: ./.github/actions/markdown powershell: - uses: ./.github/workflows/powershell.yml + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Analyze PowerShell + uses: ./.github/actions/powershell ci-status: if: always() diff --git a/.github/workflows/markdown.yml b/.github/workflows/markdown.yml deleted file mode 100644 index 5eb90f1..0000000 --- a/.github/workflows/markdown.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: markdown - -on: - workflow_call: - inputs: - globs: - description: >- - markdownlint-cli2 glob arguments, space-separated (word-split on the - runner). Prefix a glob with '!' to exclude. Default lints all markdown. - type: string - default: '**/*.md' - config: - description: Path to the markdownlint-cli2 config file in the caller repo. - type: string - default: modules/markdown/.markdownlint-cli2.jsonc - version: - description: Exact markdownlint-cli2 version to run. - type: string - default: 0.22.1 - node-version: - description: Node.js version to set up. - type: string - default: 24.17.0 - -permissions: - contents: read - -jobs: - markdownlint: - runs-on: ubuntu-latest - steps: - - name: Check out - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Set up Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: ${{ inputs.node-version }} - - name: Lint markdown - env: - CONFIG: ${{ inputs.config }} - VERSION: ${{ inputs.version }} - GLOBS: ${{ inputs.globs }} - # $GLOBS is intentionally unquoted so multiple glob arguments word-split. - run: npx --yes "markdownlint-cli2@$VERSION" --config "$CONFIG" $GLOBS diff --git a/.github/workflows/powershell.yml b/.github/workflows/powershell.yml deleted file mode 100644 index 2d50efc..0000000 --- a/.github/workflows/powershell.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: powershell - -on: - workflow_call: - inputs: - paths: - description: >- - Files/dirs to analyze (relative to the caller repo), space-separated. - Directories recurse. Default analyzes the whole repo. - type: string - default: '.' - settings: - description: Path to the PSScriptAnalyzerSettings.psd1 ruleset in the caller repo. - type: string - default: modules/powershell/PSScriptAnalyzerSettings.psd1 - exclude: - description: >- - Path substrings to skip, space-separated (e.g. intentionally-bad - fixtures). The .git directory is always skipped. - type: string - default: '' - analyzer-version: - description: Exact PSScriptAnalyzer version to install and require. - type: string - default: 1.25.0 - -permissions: - contents: read - -jobs: - pssa: - runs-on: ubuntu-latest - steps: - - name: Check out caller - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - path: caller - - # The runner script lives in this repo, not the caller's; fetch it via the - # job context (PAT-free — this repo is already authorized for the run). - - name: Check out runner script - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - repository: ${{ job.workflow_repository }} - ref: ${{ job.workflow_sha }} - path: runner - - - name: Ensure PSScriptAnalyzer - shell: pwsh - env: - REQUIRED: ${{ inputs.analyzer-version }} - run: | - $required = $env:REQUIRED - $have = Get-Module -ListAvailable PSScriptAnalyzer | - Where-Object { $_.Version -eq [version]$required } - if (-not $have) { - try { - Install-Module PSScriptAnalyzer -RequiredVersion $required -Scope CurrentUser -Force -SkipPublisherCheck -ErrorAction Stop - } catch { - Install-PSResource PSScriptAnalyzer -Version $required -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop - } - } - Import-Module PSScriptAnalyzer -RequiredVersion $required -ErrorAction Stop - Write-Output "PSScriptAnalyzer $((Get-Module PSScriptAnalyzer).Version) on pwsh $($PSVersionTable.PSVersion)" - - - name: Analyze - working-directory: caller - shell: pwsh - env: - PATHS: ${{ inputs.paths }} - SETTINGS: ${{ inputs.settings }} - EXCLUDE: ${{ inputs.exclude }} - REQUIRED: ${{ inputs.analyzer-version }} - run: | - # Wrap in @() so an empty input stays an empty array, never $null - # (a bare null would violate the runner's non-null parameter contract). - $paths = @($env:PATHS -split '\s+' | Where-Object { $_ }) - $exclude = @($env:EXCLUDE -split '\s+' | Where-Object { $_ }) - ../runner/scripts/Invoke-Pssa.ps1 -Path $paths -Settings $env:SETTINGS -AnalyzerVersion $env:REQUIRED -ExcludePath $exclude - exit $LASTEXITCODE diff --git a/README.md b/README.md index 47641e8..ceb419c 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,52 @@ # ci-workflows -Reusable, configurable CI **execution** for the melodic-software org: the -workflows that install and run each code-quality tool, plus the runner scripts -they call. +Reusable, configurable CI **execution** for the melodic-software org: composite +actions that install and run each code-quality tool, plus the runner scripts +they bundle. -Consumed by reference, never copied: +Consumed by reference from a consumer job, never copied: ```yaml jobs: markdown: - uses: melodic-software/ci-workflows/.github/workflows/markdown.yml@ + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@ + - uses: melodic-software/ci-workflows/.github/actions/markdown@ ``` +A composite action is pulled cross-repo via GitHub's scoped, read-only +installation token (distinct from the caller's repo-scoped `GITHUB_TOKEN`), so +this repo stays private with no PAT, and an action's bundled script is reached +via `$GITHUB_ACTION_PATH` without any checkout of this repo. + ## Contract -- **Configurable, not forkable.** Each reusable workflow exposes typed `inputs` - with global-standard defaults. Consumers override repo-specific scope (globs, +- **Configurable, not forkable.** Each action exposes typed `inputs` with + global-standard defaults. Consumers override repo-specific scope (globs, paths, tool versions, config location) through inputs — never by editing the - workflow. -- **Pin by SHA.** Reference every reusable workflow at a full commit SHA; - Dependabot (`github-actions`, weekly) opens bump PRs that are reviewed and - merged manually. -- **Each consumer aggregates locally.** A reusable workflow runs one tool. The - required-check contract is a single check named `ci-status`, produced by a - thin gateway job the consumer keeps local so the required-check name stays - un-nested. The gateway `needs:` the called jobs and fails if any failed or was - cancelled; a skipped job is allowed. + action. +- **Pin by SHA.** Reference every action at a full commit SHA; Dependabot + (`github-actions`, weekly) opens bump PRs that are reviewed and merged + manually. +- **Each consumer aggregates locally.** One action runs one tool inside a + consumer job. The required-check contract is a single check named `ci-status`, + produced by a thin gateway job the consumer keeps local so the required-check + name stays un-nested. The gateway `needs:` the lane jobs and fails if any + failed or was cancelled; a skipped job is allowed. -## Workflows +## Actions -- `markdown.yml` — markdownlint-cli2 over the repo's markdown. -- `powershell.yml` — PSScriptAnalyzer over the repo's PowerShell, via - `scripts/Invoke-Pssa.ps1` (per-file subprocess isolation). +- `.github/actions/markdown` — markdownlint-cli2 over the repo's markdown. +- `.github/actions/powershell` — PSScriptAnalyzer over the repo's PowerShell, + via the bundled `Invoke-Pssa.ps1` (per-file subprocess isolation). -Each input's meaning and default is documented inline in the workflow's -`inputs:` block. +Each input's meaning and default is documented inline in the action's `inputs:` +block. ## Tool configuration lives elsewhere -These workflows execute tools; they do not carry the tools' rulesets. A consumer -supplies its own config file and points the workflow at it through an input, so -adopting a workflow never couples the consumer to this repo at runtime beyond -the referenced workflow itself. +These actions execute tools; they do not carry the tools' rulesets. A consumer +supplies its own config file and points the action at it through an input, so +adopting an action never couples the consumer to this repo at runtime beyond the +referenced action itself.