Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/actions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Composite actions

Shared building blocks for the reusable workflows in
[`../workflows/`](../workflows/).

Each action lives in its own folder and follows the same layout:

```
.github/actions/<name>/
action.yml # declares inputs, outputs, and the runs: using: composite block
run.ps1 # (optional) PowerShell logic invoked by the composite
run.sh # (optional) bash logic invoked by the composite
```

## Conventions

- **Heavy logic lives in `.ps1` / `.sh` files**, not inline YAML.
Composite `run:` steps should be a single line that invokes the
script. This keeps the logic testable outside of GitHub Actions and
makes diffs readable.
- **No secrets in `with:`** — pass tokens via `env:` to avoid logging.
- **Inputs use `kebab-case`**, outputs use `kebab-case`.
- **Every input and output has a `description:`.**
- **Never interpolate `${{ inputs.* }}` or `${{ github.* }}` directly
inside a script body.** Pass them through `env:` and reference as
shell variables.
- **`shell:` is always explicit** on `run:` steps (`bash` or `pwsh`).

## Referencing from a reusable workflow in this repo

Composite actions are an implementation detail of the reusable
workflows. When a reusable workflow consumes one, reference it relative
to the repository root and pin to a full commit SHA so that callers who
pin the reusable workflow to a specific SHA get a fully reproducible
run:

```yaml
- uses: SkylineCommunications/_ReusableWorkflows/.github/actions/guard-trigger@<full-sha>
```

The pins are rewritten on merge by the maintenance script (see plan).
Do not use `@main` for intra-repo composite references.
25 changes: 25 additions & 0 deletions .github/actions/apply-catalog-identifiers/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Apply catalog identifiers
description: >
Rewrites the `id:` field in one or more `manifest.yml` files under
`CatalogInformation/` directories, using a list of
`<manifest path>=<GUID>` mappings. Validates that each manifest
exists, that the identifier is a GUID, and that the manifest has an
active (non-commented) `id:` line.

inputs:
mappings:
description: >
Newline-separated list of `<manifest.yml path>=<GUID>` entries.
When empty, the action is a no-op.
required: false
default: ""

runs:
using: composite
steps:
- name: Update Catalog Identifiers
if: inputs.mappings != ''
shell: pwsh
env:
CATALOG_IDENTIFIERS: ${{ inputs.mappings }}
run: ${{ github.action_path }}/apply-catalog-identifiers.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Rewrites `id:` in manifest.yml files according to $CATALOG_IDENTIFIERS.
# Each mapping is "<manifest path>=<GUID>", one per line. Comment lines (`# ...`)
# starting with '#' inside the manifest are preserved.
$ErrorActionPreference = 'Stop'

$rawMappings = $env:CATALOG_IDENTIFIERS
$mappings = $rawMappings -split '[\r\n]+' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }

foreach ($mapping in $mappings) {
$splitIndex = $mapping.IndexOf('=')
if ($splitIndex -lt 1 -or $splitIndex -eq ($mapping.Length - 1)) {
Write-Error "Invalid entry '$mapping'. Expected format: <manifest.yml path>=<id>."
exit 1
}

$manifestPath = $mapping.Substring(0, $splitIndex).Trim()
$identifier = $mapping.Substring($splitIndex + 1).Trim()

if ($identifier -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') {
Write-Error "Identifier '$identifier' is not a valid GUID."
exit 1
}

if ([System.IO.Path]::GetFileName($manifestPath) -ne 'manifest.yml') {
Write-Error "Manifest path '$manifestPath' must point to 'manifest.yml'."
exit 1
}

if (-not (Test-Path -Path $manifestPath -PathType Leaf)) {
Write-Error "Manifest file '$manifestPath' does not exist."
exit 1
}

$content = Get-Content -Path $manifestPath -Raw
$hasActiveIdLine = [regex]::IsMatch($content, '(?m)^(?!\s*#)\s*id:\s*.*$')

if (-not $hasActiveIdLine) {
Write-Error "No active id line found in '$manifestPath'."
exit 1
}

$updatedContent = [regex]::Replace($content, '(?m)^(?!\s*#)\s*id:\s*.*$', "id: $identifier", 1)

if ($updatedContent -eq $content) {
Write-Host "'$manifestPath' already has id: $identifier"
} else {
Set-Content -Path $manifestPath -Value $updatedContent -NoNewline
Write-Host "Updated '$manifestPath' to id: $identifier"
}
}
19 changes: 19 additions & 0 deletions .github/actions/apply-source-code-url/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Apply source_code_url to manifest
description: >
For every `manifest.yml` under a `CatalogInformation/` directory,
fills in an empty `source_code_url:` field with the GitHub URL of
the current repository.

inputs:
repository:
description: '`github.repository`. Inserted as `https://github.com/<repository>`.'
required: true

runs:
using: composite
steps:
- name: Apply SourceCode Url To Manifest
shell: pwsh
env:
REPO: ${{ inputs.repository }}
run: ${{ github.action_path }}/apply-source-code-url.ps1
25 changes: 25 additions & 0 deletions .github/actions/apply-source-code-url/apply-source-code-url.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Fills in an empty `source_code_url:` field in every manifest.yml under a
# CatalogInformation directory with https://github.com/$REPO.
$ErrorActionPreference = 'Stop'

$manifestFiles = Get-ChildItem -Recurse -Filter 'manifest.yml' |
Where-Object { $_.FullName -match '[\\/]CatalogInformation[\\/]' }

foreach ($file in $manifestFiles) {
$lines = Get-Content -Path $file.FullName
$updated = $false

for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match '^\s*source_code_url:\s*$') {
$indent = ($lines[$i] -match '^(\s*)source_code_url:')[1]
$lines[$i] = "$indent" + "source_code_url: 'https://github.com/$env:REPO'"
$updated = $true
break
}
}

if ($updated) {
Write-Host "Updating: $($file.FullName) with 'source_code_url: https://github.com/$env:REPO'"
Set-Content -Path $file.FullName -Value $lines -Encoding UTF8
}
}
17 changes: 17 additions & 0 deletions .github/actions/detect-test-runner/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: Detect test runner mode
description: >
Inspects `global.json` to determine whether the solution uses
Microsoft.Testing.Platform (MTP) or the default VSTest runner.

outputs:
mode:
description: '`mtp` when MTP is configured in global.json, otherwise `vstest`.'
value: ${{ steps.detect.outputs.test-runner-mode }}

runs:
using: composite
steps:
- name: Detect test runner mode
id: detect
shell: pwsh
run: ${{ github.action_path }}/detect-test-runner.ps1
18 changes: 18 additions & 0 deletions .github/actions/detect-test-runner/detect-test-runner.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Detects the test runner configured in global.json.
$ErrorActionPreference = 'Stop'

$globalJsonPath = Join-Path $env:GITHUB_WORKSPACE 'global.json'

if (Test-Path $globalJsonPath) {
$jsonContent = Get-Content $globalJsonPath -Raw | ConvertFrom-Json
$runner = $jsonContent.test.runner

if ($runner -eq 'Microsoft.Testing.Platform') {
Write-Host 'Detected Microsoft.Testing.Platform (MTP) test runner in global.json'
"test-runner-mode=mtp" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
return
}
}

Write-Host 'Using default VSTest test runner'
"test-runner-mode=vstest" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
16 changes: 16 additions & 0 deletions .github/actions/guard-trigger/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: Guard trigger
description: >
Fails the workflow if it was invoked via the unsupported
`pull_request_target` trigger. Use as the first step of any
reusable workflow that must not run with elevated permissions on
untrusted pull request content.

runs:
using: composite
steps:
- name: Check for unsupported trigger
if: github.event_name == 'pull_request_target'
shell: bash
run: |
echo "::error::This workflow does not support the 'pull_request_target' trigger. Use 'pull_request' instead to avoid security risks."
exit 1
50 changes: 50 additions & 0 deletions .github/actions/load-secrets/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Load secrets
description: >
Pulls a set of secrets from an Azure Key Vault, masks them, exports
them as environment variables in `$GITHUB_ENV`, and then applies any
caller-provided overrides (typically secrets passed into the
reusable workflow). Requires a prior `azure/login` step when
`use-oidc` is `true`.

inputs:
vault-name:
description: Azure Key Vault name to pull secrets from.
required: false
default: kv-master-cicd-secrets
use-oidc:
description: When `true`, fetch from Key Vault. When `false`, skip the Key Vault pull and only apply overrides.
required: true
secret-names:
description: >
Newline-separated list of Key Vault secret names to pull. Each
name is uppercased and `-` becomes `_` to form the environment
variable name (`sonar-token` becomes `SONAR_TOKEN`).
required: false
default: ""
overrides:
description: >
Newline-separated list of `ENV_VAR=VALUE` pairs. Lines with an
empty value are skipped, so it is safe to pass caller secrets
directly even when they are not set.
required: false
default: ""

runs:
using: composite
steps:
- name: Retrieve secrets from Azure Key Vault
if: inputs.use-oidc == 'true' && inputs.secret-names != ''
shell: bash
working-directory: ${{ github.action_path }}
env:
VAULT_NAME: ${{ inputs.vault-name }}
SECRET_NAMES: ${{ inputs.secret-names }}
run: bash ./load-from-keyvault.sh

- name: Apply caller-provided overrides
if: inputs.overrides != ''
shell: bash
working-directory: ${{ github.action_path }}
env:
OVERRIDES: ${{ inputs.overrides }}
run: bash ./apply-overrides.sh
27 changes: 27 additions & 0 deletions .github/actions/load-secrets/apply-overrides.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Applies caller-provided ENV=VALUE overrides from $OVERRIDES. Empty values are
# skipped so callers can safely forward optional secrets unconditionally.
set -euo pipefail

while IFS= read -r line; do
# Strip leading/trailing whitespace.
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue

if [[ "$line" != *"="* ]]; then
echo "::warning::Ignoring malformed override line (no '='): $line"
continue
fi

name="${line%%=*}"
value="${line#*=}"

if [[ -z "$value" ]]; then
continue
fi

echo "Using provided $name secret"
echo "::add-mask::$value"
echo "$name=$value" >> "$GITHUB_ENV"
done <<< "$OVERRIDES"
23 changes: 23 additions & 0 deletions .github/actions/load-secrets/load-from-keyvault.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Pulls a newline-separated list of Key Vault secret names from $SECRET_NAMES,
# fetches each value from $VAULT_NAME with the Azure CLI, masks it, and exports
# it to $GITHUB_ENV. The env var name is the secret name uppercased with '-'
# replaced by '_'.
set -euo pipefail

echo "Fetching secrets from Azure Key Vault '$VAULT_NAME'..."

# Read newline-separated secret names into an array, dropping blank lines.
mapfile -t secret_names < <(printf '%s\n' "$SECRET_NAMES" | sed '/^[[:space:]]*$/d')

for secret_name in "${secret_names[@]}"; do
secret_name="${secret_name//[[:space:]]/}"
[[ -z "$secret_name" ]] && continue

env_var_name=$(echo "$secret_name" | tr '[:lower:]' '[:upper:]' | tr '-' '_')

secret_value=$(az keyvault secret show --vault-name "$VAULT_NAME" --name "$secret_name" --query value -o tsv)

echo "::add-mask::$secret_value"
echo "$env_var_name=$secret_value" >> "$GITHUB_ENV"
done
73 changes: 73 additions & 0 deletions .github/actions/resolve-oidc/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: Resolve OIDC parameters
description: >
Determines the Azure OIDC client id, tenant id, and subscription id
to use for `azure/login`. Returns the caller-provided values when
given, falls back to the SkylineCommunications defaults when running
inside that organization, and otherwise signals that OIDC must not
be attempted.

inputs:
client-id:
description: Caller-provided Azure OIDC client id. Leave empty to fall back to defaults.
required: false
default: ""
tenant-id:
description: Caller-provided Azure OIDC tenant id.
required: false
default: ""
subscription-id:
description: Caller-provided Azure OIDC subscription id.
required: false
default: ""
repository-owner:
description: The repository owner (`github.repository_owner`). Used to enable Skyline defaults.
required: true

outputs:
client-id:
description: Resolved Azure OIDC client id.
value: ${{ steps.set_oidc.outputs.client-id }}
tenant-id:
description: Resolved Azure OIDC tenant id.
value: ${{ steps.set_oidc.outputs.tenant-id }}
subscription-id:
description: Resolved Azure OIDC subscription id.
value: ${{ steps.set_oidc.outputs.subscription-id }}
use-oidc:
description: '`true` when OIDC parameters were resolved, `false` otherwise.'
value: ${{ steps.set_oidc.outputs.use-oidc }}

runs:
using: composite
steps:
- name: Set Azure OIDC parameters
id: set_oidc
shell: bash
env:
OIDC_CLIENT_ID: ${{ inputs.client-id }}
OIDC_TENANT_ID: ${{ inputs.tenant-id }}
OIDC_SUBSCRIPTION_ID: ${{ inputs.subscription-id }}
REPO_OWNER: ${{ inputs.repository-owner }}
run: |
echo "Determining Azure OIDC parameters..."

if [[ -n "$OIDC_CLIENT_ID" ]]; then
echo "Using provided OIDC parameters"
{
echo "client-id=$OIDC_CLIENT_ID"
echo "tenant-id=$OIDC_TENANT_ID"
echo "subscription-id=$OIDC_SUBSCRIPTION_ID"
echo "use-oidc=true"
} >> "$GITHUB_OUTPUT"
elif [[ "$REPO_OWNER" == "SkylineCommunications" ]]; then
echo "Using SkylineCommunications default OIDC parameters"
{
echo "client-id=c50da9cc-ba14-4138-8595-a62d97ab0e53"
echo "tenant-id=5f175691-8d1c-4932-b7c8-ce990839ac40"
echo "subscription-id=d6cbb8df-56eb-451d-9db7-67f49cba3220"
echo "use-oidc=true"
} >> "$GITHUB_OUTPUT"
else
echo "No OIDC parameters provided and owner does not match SkylineCommunications"
echo "use-oidc=false" >> "$GITHUB_OUTPUT"
fi
Loading
Loading