Skip to content

Commit 96d9d03

Browse files
committed
fix: harden avatar fetching, output contract, and API handling
Review of the v0.1.0 surface turned up two ways an action run could stall or be subverted, plus a set of behaviours that contradicted the documented ones. - Avatar workers no longer die on exceptions outside AvatarError (malformed Content-Type, TLS failures, unreadable files): those killed the fiber and left the collector blocked until the job timed out. - Release tags are validated and passed through env, so a crafted tag cannot reach a shell in the publishing job. - Local avatars are resolved with realpath and size-capped; redirects must stay on public https, and only image content types reach the data URI. - Nothing is written when a render would be empty or a later output fails, so a good wall is never replaced by a blank one. - Sources compose: members/stargazers/sponsors work without a users list, a person found in several sources appears once with the highest weight, and config entries keep their own group. - API errors are typed: 5xx retries, non-JSON bodies and hidden sponsors become ApiError, and a stalled GraphQL cursor ends pagination. - Labels widen the canvas instead of being clipped, truncate: 0 disables truncation everywhere, and honeycomb/mosaic shrink to their content. - Outputs always report changed/paths; png.scale accepts integers; enum and no_commit mistakes fail with the accepted values.
1 parent 60e964c commit 96d9d03

26 files changed

Lines changed: 783 additions & 164 deletions

.github/workflows/ci.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ on:
55
branches: [main]
66
pull_request:
77

8+
permissions:
9+
contents: read
10+
811
jobs:
912
test:
1013
runs-on: ubuntu-latest
@@ -44,3 +47,26 @@ jobs:
4447
hall-of-fame:ci
4548
grep -q "data:image" /tmp/ws/wall.svg
4649
head -c 4 /tmp/ws/wall.png | grep -q PNG
50+
51+
# Exercises the public entry point (action.yml) the way a consumer does;
52+
# the docker job above bypasses it by running the image directly.
53+
action:
54+
runs-on: ubuntu-latest
55+
steps:
56+
- uses: actions/checkout@v5
57+
- name: Prepare a workspace config
58+
run: |
59+
mkdir -p .github
60+
printf 'users:\n - login: hahwul\n - login: octocat\noutput: ci-wall.svg\n' \
61+
> .github/hall-of-fame.yml
62+
- name: Run the action
63+
id: hof
64+
uses: ./
65+
with:
66+
no_commit: "true"
67+
- name: Check outputs
68+
run: |
69+
test "${{ steps.hof.outputs.paths }}" = "ci-wall.svg"
70+
test "${{ steps.hof.outputs.user_count }}" = "2"
71+
test "${{ steps.hof.outputs.changed }}" = "false"
72+
grep -q "data:image" ci-wall.svg

.github/workflows/release.yml

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,16 @@ jobs:
2020
registry: ghcr.io
2121
username: ${{ github.actor }}
2222
password: ${{ secrets.GITHUB_TOKEN }}
23+
# Reject anything that is not a plain semver tag before it reaches a
24+
# shell, a docker tag list, or a git ref downstream.
2325
- name: Compute tags
2426
id: meta
25-
run: echo "major=${GITHUB_REF_NAME%%.*}" >> "$GITHUB_OUTPUT"
27+
run: |
28+
case "$GITHUB_REF_NAME" in
29+
v[0-9]*.[0-9]*.[0-9]*) ;;
30+
*) echo "refusing malformed release tag: $GITHUB_REF_NAME" >&2; exit 1 ;;
31+
esac
32+
echo "major=${GITHUB_REF_NAME%%.*}" >> "$GITHUB_OUTPUT"
2633
- name: Build and push image
2734
uses: docker/build-push-action@v6
2835
with:
@@ -36,9 +43,17 @@ jobs:
3643
3744
# The image must exist before the moving major tag points at a commit
3845
# whose action.yml references it.
46+
# MAJOR goes through env, never `${{ }}` inside the script: a tag named
47+
# `v1$(...)` would otherwise be substituted into the shell and run.
3948
- name: Move major version tag
49+
env:
50+
MAJOR: ${{ steps.meta.outputs.major }}
4051
run: |
52+
case "$MAJOR" in
53+
v[0-9]*) ;;
54+
*) echo "refusing unexpected major tag: $MAJOR" >&2; exit 1 ;;
55+
esac
4156
git config user.name "github-actions[bot]"
4257
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
43-
git tag -f "${{ steps.meta.outputs.major }}"
44-
git push origin "${{ steps.meta.outputs.major }}" --force
58+
git tag -f "$MAJOR"
59+
git push origin "$MAJOR" --force

README.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Generate avatar-wall art for your repository — a "hall of fame" of the people
66
| :--: | :-------: | :----: |
77
| ![grid](examples/grid.svg) | ![honeycomb](examples/honeycomb.svg) | ![mosaic](examples/mosaic.svg) |
88

9+
*(Curated samples from [`examples/showcase.yml`](examples/showcase.yml). This repository also runs the action on itself every week — the live result lands in [`docs/`](docs/).)*
10+
911
- **Three styles** — classic grid (circle/rounded/square), honeycomb hexagons, and a weight-tiered mosaic where your top contributors literally loom larger.
1012
- **Many sources, one wall** — your curated `users` list, repository contributors, org members, stargazers, and GitHub Sponsors (tier amounts become weights). Mix freely; your YAML entries always win.
1113
- **Sections & roles** — split the wall into titled groups (say, *Contributors* and *Special Thanks*) and tag people with a role line (*Creator*, *Design*, *Docs*) — made for honoring the folks the contributors API can't see.
@@ -20,7 +22,7 @@ Create `.github/hall-of-fame.yml`:
2022
```yaml
2123
source: both # my list + repository contributors
2224
style: grid
23-
output: HALL_OF_FAME.svg
25+
output: HALL_OF_FAME.svg # or drop this and use `outputs:` for several files
2426

2527
users:
2628
- login: hahwul
@@ -64,12 +66,12 @@ Then embed the result in your README:
6466

6567
| Input | Default | Description |
6668
| ----- | ------- | ----------- |
67-
| `config` | `.github/hall-of-fame.yml` | Path to the config YAML |
68-
| `token` | `${{ github.token }}` | Token for the contributors API (`source: contributors`/`both`) |
69-
| `no_commit` | `false` | Generate files but skip commit/push |
69+
| `config` | `.github/hall-of-fame.yml` | Path to the config YAML, relative to the repository root |
70+
| `token` | `${{ github.token }}` | GitHub API token. Required for `sponsors`; also lifts rate limits and reaches private repos for the other API sources |
71+
| `no_commit` | `false` | Generate files but skip commit/push (must be `true` or `false`) |
7072
| `commit_message` | `chore: update hall of fame` | Commit message |
7173

72-
Outputs: `svg_path` (comma-separated generated paths), `user_count`, `changed` (whether a commit was pushed).
74+
Outputs: `paths` (comma-separated generated files, SVG and PNG), `user_count`, and `changed` (whether a commit was pushed; `false` when `no_commit` is set). `svg_path` still works as an alias for `paths`.
7375

7476
## Configuration
7577

@@ -162,11 +164,16 @@ png:
162164
scale: 2 # rasterization zoom for .png outputs
163165
```
164166
165-
When `source: both`, your `users` entries win over API data field by field — set a custom `name` or `weight` while the contribution count fills everyone else's. Users without a `group` render first in an untitled section; groups follow the `groups` order (or first mention in the config). This is the recipe for honoring people the API misses — unlinked commit emails, design or docs work: add them to `users` with a `role` and their own section.
167+
When `source: both`, your `users` entries win over API data field by field — set a custom `name` or `weight` while the contribution count fills everyone else's. Placement is always yours: an entry without `group` renders in the untitled leading section even if the API put that person in one, so add `group:` when you want them filed under a heading. Someone returned by more than one API source (a contributor who also sponsors) appears once, keeping the highest weight and the first source's group.
168+
169+
This is the recipe for honoring people the API misses — unlinked commit emails, design or docs work: add them to `users` with a `role` and their own section.
166170

167171
## Notes
168172

173+
- A config file is required; the action fails if `.github/hall-of-fame.yml` (or the path you pass as `config`) does not exist.
169174
- The workflow needs `permissions: contents: write` to push the generated file, and a `concurrency` group avoids racing pushes on busy repositories.
175+
- Avatars link to profiles and carry name/role tooltips, but a README embed (`![](wall.svg)`) renders as an `<img>`, where neither is active. Open the SVG directly — or inline it — to get links.
176+
- `members` returns public organization members only; a token with `read:org` is needed for the rest. Stargazers arrive oldest-first with no weight, so under the default `sort: weight` they trail contributors — use `sort: none` to keep the API order.
170177
- On `pull_request` events the checkout is a detached HEAD, so pushes fail — use push/schedule/dispatch triggers, or set `no_commit: true` and handle the file yourself.
171178
- SVG size grows with user count (roughly 5–15 KB per avatar). Use `limit` and moderate avatar sizes for large walls.
172179
- With `mode: auto` (the default) the SVG contains both palettes and a `prefers-color-scheme` media query, so it follows GitHub's light/dark theme. PNGs can't adapt, so `.png` outputs pin `auto` to the light palette — add a second output with `mode: dark` for a pair.
@@ -179,10 +186,12 @@ The action binary is also a local CLI:
179186

180187
```bash
181188
shards build --release
182-
bin/hall-of-fame --config examples/showcase.yml # writes examples/*.svg
189+
bin/hall-of-fame --config examples/showcase.yml # regenerates the committed examples/*.svg
183190
bin/hall-of-fame -c my.yml --commit # opt in to commit/push locally
184191
```
185192

193+
`--config` is resolved against the current directory, while output paths and local `avatar_url` files are resolved against `--workspace` (the current directory by default; `GITHUB_WORKSPACE` inside the action). Committing happens automatically when `GITHUB_ACTIONS=true` — including on runners that emulate it, such as act or Forgejo — and otherwise only with `--commit`.
194+
186195
## Development
187196

188197
```bash
@@ -193,7 +202,9 @@ crystal tool format
193202
bin/ameba src spec
194203
```
195204

196-
Release flow: pushing a `v*` tag builds a multi-arch image to `ghcr.io/crystal-actions/hall-of-fame` and force-moves the major tag (`v0`, `v1`, …). The image is pushed before the git tag moves, so the moving tag always references an existing image.
205+
Release flow: pushing a `vX.Y.Z` tag builds a multi-arch image to `ghcr.io/crystal-actions/hall-of-fame` and force-moves the major tag (`v0`, `v1`, …). The image is pushed before the git tag moves, so the moving tag always references an existing image.
206+
207+
While the repository is private, `action.yml` still uses `image: Dockerfile`, so consumers build the image on their runner (roughly a minute on a cold cache) and the published GHCR image is not used yet. Switching `action.yml` to `docker://ghcr.io/crystal-actions/hall-of-fame:v0` is part of going public — runners pull GHCR anonymously, which only works once the package is public.
197208

198209
## License
199210

action.yml

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,18 @@ branding:
88

99
inputs:
1010
config:
11-
description: Path to the config YAML
11+
description: Path to the config YAML, relative to the repository root
1212
required: false
1313
default: .github/hall-of-fame.yml
1414
token:
15-
description: Token used for the contributors API (only needed for source contributors/both)
15+
description: >-
16+
Token for the GitHub API. Required for the sponsors source; also raises
17+
rate limits and grants access to private repositories for the
18+
contributors, members, and stargazers sources.
1619
required: false
1720
default: ${{ github.token }}
1821
no_commit:
19-
description: Generate the files but skip commit/push
22+
description: Generate the files but skip commit/push (must be true or false)
2023
required: false
2124
default: "false"
2225
commit_message:
@@ -25,12 +28,14 @@ inputs:
2528
default: "chore: update hall of fame"
2629

2730
outputs:
31+
paths:
32+
description: Comma-separated paths of the generated files (SVG and PNG)
2833
svg_path:
29-
description: Comma-separated paths of the generated SVG files
34+
description: Deprecated alias for `paths`, kept for existing workflows
3035
user_count:
31-
description: Number of users rendered
36+
description: Number of users rendered in the last output
3237
changed:
33-
description: Whether the files changed and were committed
38+
description: Whether the files changed and were committed (false when no_commit is set)
3439

3540
runs:
3641
using: docker

0 commit comments

Comments
 (0)