Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .github/linters/.jscpd.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"threshold": 5
"threshold": 5,
"ignore": ["actions/**/README.md"]
}
4 changes: 2 additions & 2 deletions .github/workflows/__test-action-parse-ci-reports.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
pull-requests: write
security-events: write
with:
working-directory: ./actions/parse-ci-reports
working-directory: ./actions/quality/parse-ci-reports
build: null
lint: null
test: '{ "coverage": null }'
Expand Down Expand Up @@ -89,7 +89,7 @@ jobs:

- name: Act - Run parse-ci-reports action
id: parse-ci-reports
uses: $/actions/parse-ci-reports
uses: $/actions/quality/parse-ci-reports
with:
report-paths: |
test-reports/junit.xml
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

Opinionated GitHub Actions and reusable workflows for foundational continuous-integration automation that can be shared across Hoverkraft projects.

See the [documentation](docs/README.md) for detailed guides.

## Actions

### Workflow & repository automation
Expand All @@ -32,9 +34,13 @@ Opinionated GitHub Actions and reusable workflows for foundational continuous-in
- [Get matrix outputs](actions/get-matrix-outputs/README.md) - aggregates outputs across matrix jobs for downstream steps.
- [Set matrix output](actions/set-matrix-output/README.md) - writes structured outputs that can be consumed by other matrix jobs.

### Quality checks & reporting

- [Codecov](actions/quality/codecov/README.md) - uploads coverage from any language using OIDC, with optional explicit report selection.
- [Parse CI reports](actions/quality/parse-ci-reports/README.md) - parses CI reports (tests, linting, coverage) into GitHub summaries and Markdown for PR comments.

### Repository insights & utilities

- [Parse CI reports](actions/parse-ci-reports/README.md) - parses CI reports (tests, linting, coverage) into GitHub summaries and Markdown for PR comments.
- [Repository owner is organization](actions/repository-owner-is-organization/README.md) - checks whether the repository owner is an organization.
- [Working directory](actions/working-directory/README.md) - resolves and validates a working directory path, exposing absolute and workspace-relative outputs.
- [Slugify](actions/slugify/README.md) - converts free-form strings into GitHub-friendly slugs.
Expand All @@ -58,7 +64,8 @@ Contributions are welcome! Please see the [contributing guidelines](https://gith

### Action structure pattern

All actions follow a consistent structure:
Actions follow the structure below. Related quality actions are grouped under
`actions/quality/` using the same structure.

```text
actions/{action-name}/
Expand Down
563 changes: 0 additions & 563 deletions actions/parse-ci-reports/README.md

This file was deleted.

62 changes: 62 additions & 0 deletions actions/quality/codecov/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<!-- header:start -->

# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItdXBsb2FkLWNsb3VkIiBjb2xvcj0iYmx1ZSI+PHBvbHlsaW5lIHBvaW50cz0iMTYgMTYgMTIgMTIgOCAxNiI+PC9wb2x5bGluZT48bGluZSB4MT0iMTIiIHkxPSIxMiIgeDI9IjEyIiB5Mj0iMjEiPjwvbGluZT48cGF0aCBkPSJNMjAuMzkgMTguMzlBNSA1IDAgMCAwIDE4IDloLTEuMjZBOCA4IDAgMSAwIDMgMTYuMyI+PC9wYXRoPjxwb2x5bGluZSBwb2ludHM9IjE2IDE2IDEyIDEyIDggMTYiPjwvcG9seWxpbmU+PC9zdmc+) GitHub Action: Codecov

<!-- header:end -->
<!-- overview:start -->

## Overview

Action to upload coverage to Codecov.

- **Automatic dependency management**: Automatically detects and installs missing dependencies (Git, cURL, gnupg) using pkgxdev
- **Configuration fixes**: Automatically fixes pkgxdev configuration issues with unexpanded environment variables in gpgconf.ctl and .curlrc files
- **Cleanup**: Uninstalls dependencies after Codecov upload is complete
- **OIDC support**: Uses OIDC authentication with Codecov for secure uploads

Note that this action must have write permission for id-token for this to work:

```yml
permissions:
id-token: write
```

<!-- overview:end -->
<!-- usage:start -->

## Usage

```yaml
- uses: hoverkraft-tech/ci-github-common/actions/quality/codecov@<commit-sha>
with:
# Working directory where coverage files are located.
# Can be absolute or relative to the repository root.
#
# Default: `.`
working-directory: .

# Optional comma-separated coverage report files. When supplied, only these files are uploaded and automatic discovery is disabled.
files: ""

# Fail the job when Codecov upload fails. Enable when coverage publication is required.
# Default: `false`
fail-ci-if-error: "false"
```

<!-- usage:end -->
<!-- inputs:start -->

## Inputs

| **Input** | **Description** | **Required** | **Default** |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------- |
| **`working-directory`** | Working directory where coverage files are located. | **false** | `.` |
| | Can be absolute or relative to the repository root. | | |
| **`files`** | Optional comma-separated coverage report files. When supplied, only these files are uploaded and automatic discovery is disabled. | **false** | - |
| **`fail-ci-if-error`** | Fail the job when Codecov upload fails. Enable when coverage publication is required. | **false** | `false` |

<!-- inputs:end -->
<!-- secrets:start -->
<!-- secrets:end -->
<!-- outputs:start -->
<!-- outputs:end -->
199 changes: 199 additions & 0 deletions actions/quality/codecov/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
name: "Codecov"
description: |
Action to upload coverage to Codecov.

- **Automatic dependency management**: Automatically detects and installs missing dependencies (Git, cURL, gnupg) using pkgxdev
- **Configuration fixes**: Automatically fixes pkgxdev configuration issues with unexpanded environment variables in gpgconf.ctl and .curlrc files
- **Cleanup**: Uninstalls dependencies after Codecov upload is complete
- **OIDC support**: Uses OIDC authentication with Codecov for secure uploads

Note that this action must have write permission for id-token for this to work:

```yml
permissions:
id-token: write
```

author: hoverkraft
branding:
icon: upload-cloud
color: blue

inputs:
working-directory:
description: |
Working directory where coverage files are located.
Can be absolute or relative to the repository root.
required: false
default: "."

files:
description: Optional comma-separated coverage report files. When supplied, only these files are uploaded and automatic discovery is disabled.
default: ""
fail-ci-if-error:
description: Fail the job when Codecov upload fails. Enable when coverage publication is required.
default: "false"

runs:
using: "composite"
steps:
# jscpd:ignore-start
- id: working-directory
uses: $/actions/working-directory
with:
working-directory: ${{ inputs.working-directory }}
enforce-path-in-workspace: "false"
# jscpd:ignore-end

# Check and install dependencies for codecov
- name: Check Codecov dependencies
id: check-codecov-deps
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
// Check which dependencies are missing
const deps = {"git": "git", "curl": "curl", "gpg": "gnupg.org"};
const missingDeps = [];

for (const [dep, pkg] of Object.entries(deps)) {
try {
await io.which(dep, true);
} catch {
missingDeps.push(pkg);
}
}

if (missingDeps.length > 0) {
core.setOutput('missing-deps', missingDeps.join(' '));
}

- name: Install missing Codecov dependencies
if: steps.check-codecov-deps.outputs.missing-deps
uses: pkgxdev/setup@4d4ae97af87ccb39ab8be4e073dea697fef2c6f7 # v5.0.0
with:
+: ${{ steps.check-codecov-deps.outputs.missing-deps }}

- name: Fix unexpanded environment variables
if: steps.check-codecov-deps.outputs.missing-deps
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const templatePattern = /\$\{([^}:]+)(?::-([^}]*))?\}/g;
const unsafeSyntaxPattern = /`|\$\(/;
const resolving = new Set();
const resolvedCache = new Map();

const hasTemplate = (value) => {
if (typeof value !== 'string') {
return false;
}

templatePattern.lastIndex = 0;
return templatePattern.test(value);
};

const getReferencedVariables = (value) => {
templatePattern.lastIndex = 0;
return Array.from(value.matchAll(templatePattern), (match) => match[1]);
};

const sanitizeForDoubleQuotes = (value) => value.replace(/(["\\])/g, '\\$1');

const resolveEnvVariable = async (envKey) => {
const rawValue = process.env[envKey];

if (!hasTemplate(rawValue)) {
core.debug(`Skipping ${envKey} because it does not match the template pattern.`);
return rawValue;
}

if (unsafeSyntaxPattern.test(rawValue)) {
core.debug(`Skipping ${envKey} because it contains unsupported command substitution syntax.`);
return rawValue;
}

if (resolvedCache.has(envKey)) {
return resolvedCache.get(envKey);
}

if (resolving.has(envKey)) {
core.debug(`Detected circular reference while resolving ${envKey}, falling back to defaults.`);
return rawValue;
}

resolving.add(envKey);

const referencedVariables = getReferencedVariables(rawValue);
for (const variableName of referencedVariables) {
if (variableName === envKey) {
continue;
}

await resolveEnvVariable(variableName);
}

const envForBash = { ...process.env };
delete envForBash[envKey];

const scriptLines = [
'set -eo pipefail',
`resolved_value="${sanitizeForDoubleQuotes(rawValue)}"`,
"printf '%s' \"${resolved_value}\"",
];

let resolvedValue = rawValue;
try {
const { stdout } = await exec.getExecOutput(
'bash',
['-c', scriptLines.join('\n')],
{ env: envForBash, silent: true }
);

resolvedValue = stdout.replace(/\r?\n$/, '');
} catch (error) {
core.warning(`Failed to resolve ${envKey}: ${error.message}`);
}

resolving.delete(envKey);
resolvedCache.set(envKey, resolvedValue);

if (resolvedValue !== rawValue) {
core.debug(`Resolved ${envKey}: "${rawValue}" -> "${resolvedValue}"`);
process.env[envKey] = resolvedValue;
core.exportVariable(envKey, resolvedValue);
} else {
core.debug(`No changes for ${envKey}, skipping update.`);
}

return resolvedValue;
};

for (const envKey of Object.keys(process.env)) {
await resolveEnvVariable(envKey);
}

- name: 📊 Upload coverage to Codecov
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
directory: ${{ steps.working-directory.outputs.workspace-relative-path }}
root_dir: ${{ steps.working-directory.outputs.workspace-relative-path }}
working-directory: ${{ steps.working-directory.outputs.workspace-relative-path }}
files: ${{ inputs.files }}
disable_search: ${{ inputs.files != '' }}
use_oidc: true
disable_telem: true
fail_ci_if_error: ${{ inputs.fail-ci-if-error }}

# Uninstall pkgxdev dependencies after codecov is done
- name: Uninstall Codecov dependencies
if: always() && steps.check-codecov-deps.outputs.missing-deps
shell: bash
env:
MISSING_DEPS: ${{ steps.check-codecov-deps.outputs.missing-deps }}
run: |
if command -v pkgx &> /dev/null; then
for dep in $MISSING_DEPS; do
echo "Uninstalling $dep..."
pkgx uninstall "$dep" 2>/dev/null || true
done
fi
Loading
Loading