Skip to content
Open
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
73 changes: 73 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,79 @@ jobs:
PAT_1: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY_OWNER: "rickstaa"

action:
name: Action (core_version ${{ matrix.core_version || 'empty' }})

strategy:
fail-fast: false
matrix:
core_version: ["", "2.1.3", "v2"]
runs-on: ubuntu-latest

permissions:
contents: read

steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Generate stats card
id: generate
uses: ./
with:
card: stats
options: username=rickstaa
path: profile/stats.svg
core_version: ${{ matrix.core_version }}

- name: Assert the card was written
run: grep -q "<svg" "${{ steps.generate.outputs.path }}"
shell: bash

# Resolves input, not saved spec (pnpm add rewrites "v2" → "^2.1.5").
# Empty input uses the lockfile pin (whatever package.json holds).
# pkg get requires bracket syntax; pnpm 11 rejects @ in dot paths.
- name: Assert the installed core version matches core_version
run: |
spec=$(pnpm pkg get "dependencies[\"$PKG\"]" | tr -d '"')
expected=$(pnpm info "$PKG@${CORE_VERSION:-$spec}" version | tail -1 | tr -d "'" | awk '{print $NF}')
installed=$(pnpm list --depth 0 "$PKG" | grep -F "$PKG@" | sed 's/.*@//')
echo "input='$CORE_VERSION' spec='$spec' expected='$expected' installed='$installed'"
[ "$installed" = "$expected" ] || { echo "::error::installed core '$installed', expected '$expected' for core_version '$CORE_VERSION'"; exit 1; }
shell: bash
env:
CORE_VERSION: ${{ matrix.core_version }}
PKG: "@stats-organization/github-readme-stats-core"

action-invalid-core-version:
name: Action rejects an invalid core_version

runs-on: ubuntu-latest

permissions:
contents: read

steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Generate stats card
id: generate
continue-on-error: true
uses: ./
with:
card: stats
options: username=rickstaa
core_version: "latest && whoami"

- name: Assert the action failed
run: |
if [ "${{ steps.generate.outcome }}" != "failure" ]; then
echo "::error::expected the action to reject the core_version input"
exit 1
fi
shell: bash

code-checks:
name: Code checks

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ This action is a recommended deployment option. You can also use [our public Git
- `options`: Card options as a query string (`key=value&...`) or JSON. If `username` is omitted, the action uses the repository owner.
- `path`: Output path for the SVG file. Defaults to `profile/<card>.svg`.
- `token`: GitHub token (PAT or `GITHUB_TOKEN`). For private repo stats, use a [PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) with `repo` and `read:user` scopes. For any gist, use a PAT with `gist` scope.
- `core_version`: Version of [GitHub Stats Extended](https://github.com/stats-organization/github-stats-extended) to use internally. When omitted, the action uses the latest 2.x.x version.
- `core_version`: Version of [GitHub Stats Extended](https://github.com/stats-organization/github-stats-extended) to use internally. Accepts any npm version, range or dist-tag (`2.1.3`, `v2`, `latest`).\
When omitted, the action uses the latest 2.x.x version. Set an exact version for reproducible runs, or an empty string to use the version pinned by the action release you reference.
- `fail_on_error`: Fail the action when data fetching fails (e.g. a GitHub API rate limit) instead of writing the "Something went wrong" error card.\
Defaults to `false` for backwards compatibility.

Expand Down
52 changes: 40 additions & 12 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: GitHub Readme Stats Action
description: Generate GitHub Readme Stats cards in GitHub Actions.
author: stats-organization

inputs:
card:
description: Card type to generate (stats, top-langs, pin, wakatime, gist).
Expand All @@ -18,15 +19,17 @@ inputs:
required: false
default: ""
core_version:
description: Version of @stats-organization/github-readme-stats-core to use internally.
description: >-
Version of @stats-organization/github-readme-stats-core to use internally (any npm version, range or dist-tag).
Defaults to the latest 2.x.
Set to an empty string to use the version pinned by this action release instead.
required: false
default: "v2"
fail_on_error:
description: >-
Fail the action when the card renderer reports a data-fetch error (e.g. a
GitHub API rate limit) instead of writing the "Something went wrong" error
card. Defaults to "false" to preserve backwards-compatible behaviour; set
to "true" to fail the action on a broken card.
Fail the action when the card renderer reports a data-fetch error
(e.g. a GitHub API rate limit) instead of writing the "Something went wrong" error card.
Defaults to "false" to preserve backwards-compatible behavior; set to "true" to fail the action on a broken card.
required: false
default: "false"
outputs:
Expand All @@ -36,9 +39,8 @@ outputs:
runs:
using: composite
steps:
# pnpm/action-setup's `package_json_file` and setup-node's `node-version-file` are both
# resolved relative to $GITHUB_WORKSPACE, but our package.json lives under
# ${{ github.action_path }} — convert to a workspace-relative path.
# Both file inputs below resolve relative to $GITHUB_WORKSPACE, not to
# ${{ github.action_path }}, where our package.json lives.
- name: Compute workspace-relative package.json path
id: resolve_path
run: echo "package_json=$(realpath --relative-to="$GITHUB_WORKSPACE" "${{ github.action_path }}/package.json")" >> "$GITHUB_OUTPUT"
Expand All @@ -51,13 +53,39 @@ runs:
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: ${{ steps.resolve_path.outputs.package_json }}

# --ignore-scripts skips husky's prepare hook, which fails without husky installed.
# https://github.com/stats-organization/github-readme-stats-action/issues/63
- name: Install dependencies
# --prod skips devDependencies (husky, vitest, etc.); --ignore-scripts skips the
# husky prepare hook, which would otherwise fail since husky isn't installed.
# https://github.com/stats-organization/github-readme-stats-action/issues/63
run: pnpm install --frozen-lockfile --prod --ignore-scripts
shell: bash
working-directory: ${{ github.action_path }}

# Swaps out core only; everything else stays locked.
# `core_version` is validated and passed via env, never interpolated.
# Delete and prune because `pnpm add` alone keeps the locked version ("v2" gives the pin)
# and `pnpm remove` would reinstall the devDependencies.
# `pkg delete` needs brackets: pnpm 11 rejects `@` in a dot path.
- name: Install the requested core version
if: inputs.core_version != ''
run: |
case "$CORE_VERSION" in
*[!a-zA-Z0-9._-]*)
echo "::error::core_version must contain only a-zA-Z0-9._- characters."
exit 1
;;
esac
pnpm pkg delete "dependencies[\"$CORE_PACKAGE_NAME\"]"
pnpm install --no-frozen-lockfile --prod --ignore-scripts --loglevel error
pnpm add "$CORE_PACKAGE_NAME@$CORE_VERSION" --prod --ignore-scripts --loglevel error
installed=$(pnpm pkg get version -C "node_modules/$CORE_PACKAGE_NAME")
echo "Installed $CORE_PACKAGE_NAME@$installed (requested \"$CORE_VERSION\")"
shell: bash
working-directory: ${{ github.action_path }}
env:
CORE_VERSION: ${{ inputs.core_version }}
CORE_PACKAGE_NAME: "@stats-organization/github-readme-stats-core"

- name: Generate card
id: generate
run: node ${{ github.action_path }}/index.js
Expand All @@ -66,9 +94,9 @@ runs:
INPUT_CARD: ${{ inputs.card }}
INPUT_OPTIONS: ${{ inputs.options }}
INPUT_PATH: ${{ inputs.path }}
INPUT_CORE_VERSION: ${{ inputs.core_version }}
INPUT_FAIL_ON_ERROR: ${{ inputs.fail_on_error }}
PAT_1: ${{ inputs.token || github.token }}

branding:
icon: bar-chart-2
color: blue
76 changes: 6 additions & 70 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,74 +1,11 @@
import { exec } from "node:child_process";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";

import { getInput, info, setFailed, setOutput, warning } from "@actions/core";

const execAsync = promisify(exec);
const CORE_PACKAGE_NAME = "@stats-organization/github-readme-stats-core";
const supportedCoreExports = ["api", "topLangs", "pin", "wakatime", "gist"];

const validateCoreVersion = (value) => {
const pattern = /^[a-zA-Z0-9._-]*$/;
if (!pattern.test(value)) {
throw new Error("core_version must contain only a-zA-Z0-9._- characters.");
}
return value;
};

/**
* Install the requested core package into an isolated temporary directory.
* @param {string} version Package version.
* @returns {Promise<string>} Directory containing the installed package.
*/
const installCorePackage = async (version) => {
const installDir = await mkdtemp(path.join(os.tmpdir(), "grs-core-"));
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const packageSpec = `${CORE_PACKAGE_NAME}@${version}`;

try {
await writeFile(
path.join(installDir, "package.json"),
JSON.stringify({ private: true, type: "module" }),
"utf8",
);

await execAsync(
`${npmCommand} install --no-save --ignore-scripts --no-package-lock ${packageSpec}`,
{
cwd: installDir,
env: process.env,
},
);

return installDir;
} catch (error) {
throw new Error(
`Failed to install ${CORE_PACKAGE_NAME}@${version}: ${error}`,
);
}
};

/**
* Load the core package either from the bundled dependency or from an isolated install.
* @param {string} version Package version.
* @returns {Promise<Record<string, unknown>>} Loaded module and cleanup callback.
*/
const loadCoreModule = async (version) => {
if (!version) {
return await import(CORE_PACKAGE_NAME);
}

const installDir = await installCorePackage(version);
const installRequire = createRequire(path.join(installDir, "package.json"));
const modulePath = installRequire.resolve(CORE_PACKAGE_NAME);
return await import(pathToFileURL(modulePath).href);
};

/**
* Build the map of supported card handlers from the loaded core module.
* @param {Record<string, unknown>} coreModule Loaded core package module.
Expand Down Expand Up @@ -183,10 +120,11 @@ const run = async () => {
const card = getInput("card", { required: true }).toLowerCase();
const optionsInput = getInput("options");
const outputPathInput = getInput("path");
const coreVersion = validateCoreVersion(getInput("core_version"));
const failOnError = /^(true|1|yes)$/i.test(getInput("fail_on_error"));

const coreModule = await loadCoreModule(coreVersion);
// `core_version` is applied by the action's install step.
// Dynamic so a broken install surfaces through `setFailed` instead of a raw module-load error.
const coreModule = await import(CORE_PACKAGE_NAME);

// Map of card types to their respective API handlers.
const cardHandlers = createCardHandlers(coreModule);
Expand All @@ -206,10 +144,8 @@ const run = async () => {
const result = await handler(query);
const svg = result?.content;

// The core renderer never throws on a data-fetch error; it returns a `status`
// starting with "error" and a "Something went wrong" SVG. When fail_on_error
// is enabled, fail the action so the broken card is never written or committed.
// Older core versions may not return a `status`, so this is a no-op for them.
// Renderer returns error* status + fallback SVG on fetch errors.
// With fail_on_error, fail before the broken card is written/committed.
if (failOnError && String(result?.status).startsWith("error")) {
throw new Error(
`Card generation failed while fetching data (${result.status}).`,
Expand Down
2 changes: 2 additions & 0 deletions knip.jsonc
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{
"$schema": "./node_modules/knip/schema.json",

// index.js imports core through a variable specifier, which knip cannot resolve.
"ignoreDependencies": ["@stats-organization/github-readme-stats-core"]
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
},
"devDependencies": {
"husky": "9.1.7",
"knip": "6.27.0",
"knip": "6.32.3",
"lint-staged": "17.1.0",
"prettier": "3.9.5",
"vitest": "4.1.10"
Expand Down
Loading
Loading