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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ This repository contains a set of exercises to learn Github Actions.
* [Storing Artifacts](./labs/storing-artifacts.md)
* [Building Docker images](./labs/docker-image.md)
* [Systems test](./labs/systems-test.md)
* [Reusable workflows](./labs/reusable.md)
* [Reusable workflows](./labs/reusable-workflow.md)
* [Pull Request based workflow](./labs/pr-workflow.md)
* [Build app on multiple environments](./labs/matrix-builds.md)

Expand Down
54 changes: 54 additions & 0 deletions ci/format_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
Format and display a summary from a JSON file.
Takes a JSON file path as a command-line argument.
"""
import json
import sys
from typing import Any


def load_json_file(file_path: str) -> dict[str, Any]:
"""Load and parse a JSON file with proper error handling.

Args:
file_path: Path to the JSON file to load

Returns:
The parsed JSON data as a dictionary

Raises:
SystemExit: On file or JSON parsing errors
"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
print(f"Error: File '{file_path}' not found", file=sys.stderr)
sys.exit(2)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in '{file_path}': {e}", file=sys.stderr)
sys.exit(2)
except OSError as e:
print(f"Error: Failed to read '{file_path}': {e}", file=sys.stderr)
sys.exit(2)


def main() -> None:
"""Load a number summary in json format and output a human readable string instead
"""
if len(sys.argv) != 2:
print("Usage: python format_summary.py <summaryfile>", file=sys.stderr)
sys.exit(1)

json_file = sys.argv[1]
summary = load_json_file(json_file)

if not isinstance(summary, dict) or summary.get('count', 0) == 0:
print('No numbers provided')
else:
print(f"count={summary['count']}, sum={summary['sum']}, avg={summary['avg']:.2f}, min={summary['min']}, max={summary['max']}")


if __name__ == "__main__":
main()
54 changes: 54 additions & 0 deletions ci/print_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
Simple helper used by the composite action exercise.
Reads JSON from stdin (or a file path passed as arg) containing a list of numbers
and prints a small summary: count, sum, average, min, max.
"""
import sys
import json

def summarize(numbers: list[float]) -> dict:
if not numbers:
return {"count": 0, "sum": 0, "avg": None, "min": None, "max": None}
total = sum(numbers)
return {
"count": len(numbers),
"sum": total,
"avg": total / len(numbers),
"min": min(numbers),
"max": max(numbers),
}


def main():
data = None
if len(sys.argv) > 1:
try:
with open(sys.argv[1], "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
print(f"Failed to read {sys.argv[1]}: {e}", file=sys.stderr)
sys.exit(2)
else:
try:
data = json.load(sys.stdin)
except Exception:
print("No JSON input provided on stdin and no file given", file=sys.stderr)
sys.exit(1)

if not isinstance(data, list):
print("Expected a JSON array of numbers", file=sys.stderr)
sys.exit(3)

try:
numbers = [float(x) for x in data]
except Exception:
print("All items in the array must be numeric", file=sys.stderr)
sys.exit(4)

out = summarize(numbers)
print(json.dumps(out))


if __name__ == "__main__":
main()
100 changes: 100 additions & 0 deletions labs/composite-action.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Composite actions

In this hands-on lab you'll create a small composite action that runs a few shell steps plus the Python helper located at `ci/print_summary.py`.

The goal is to learn how to: create a composite action, accept inputs, expose outputs, and call it from a workflow.

This lab contains the following steps:

- Create the composite action metadata
- Use the composite action in a simple workflow
- Run the workflow locally (or on GitHub Actions) and inspect the output

## Create the composite action

1. Create a new directory `.github/actions/summary-action` in the repository.
2. Add an `action.yml` file with the following behaviour:

- Accept an input `numbers` which is a JSON array encoded as a string (for simplicity)
- Pipe that string to the Python helper `ci/print_summary.py` via stdin
- Save the printed JSON summary to an output parameter called `summary`

For more information on action metadata format and the composite action syntax, see the GitHub Docs: <https://docs.github.com/en/actions/tutorials/create-actions/create-a-composite-action#creating-an-action-metadata-file>

<details>
<summary>Solution</summary>

```yaml
name: Summary action
description: "Pipe numbers into a Python helper and print a small summary"
inputs:
numbers:
description: 'JSON array of numbers as a string, e.g. "[1,2,3]"'
required: true
outputs:
summary:
description: 'JSON summary produced by the helper'
value: ${{ steps.set-output.outputs.summary }}
runs:
using: "composite"
steps:
- name: Run python summarizer (stdin)
shell: bash
run: |
echo "${{ inputs.numbers }}" | python3 ci/print_summary.py > summary.json
cat summary.json

- name: Set output
id: set-output
shell: bash
run: echo "summary=$(cat summary.json)" >> $GITHUB_OUTPUT
```

</details>

Notes:

- The composite action uses the `composite` runner so it can string together multiple steps.
- We store the temporary file path in an intermediate output (optional). The important part is that the final step writes the `summary` to `$GITHUB_OUTPUT` so the action exposes the output.

## Use the composite action

1. Create a workflow `.github/workflows/use-summary.yml` with a manual trigger (`workflow_dispatch`).
2. Add a job that calls the action with a `numbers` value.

Example consumer (solution):

<details>
<summary>Solution</summary>

```yaml
name: Use summary action

on: [workflow_dispatch]

jobs:
call-summary:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Call summary composite action
id: summary
uses: ./.github/actions/summary-action
with:
numbers: '[10, 20, 30, 40]'

- name: Print action output
run: |
echo "Summary output: ${{ steps.summary.outputs.summary }}"
```

</details>

## Run the workflow

- You can dispatch the workflow from the GitHub UI (Workflow -> Run workflow) or run the steps locally using a runner that supports Actions (for example, act). The important part is that the workflow demonstrates how inputs and outputs flow through the composite action.

## Summary

You've created a composite action that runs multiple steps and produces an output. You also learned how to call that composite action from a workflow and read its outputs.
File renamed without changes.
110 changes: 110 additions & 0 deletions labs/reusable-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Reusable workflow that uses a composite action

This lab builds on the composite action exercise. The reusable workflow will call the composite action `summary-action` (created earlier) to compute a small summary of a list of numbers and expose that summary as outputs. A consuming workflow will call the reusable workflow and then print the outputs.

Follow these steps:

## Create the reusable workflow

1. Create `.github/workflows/reusable.yml`.
2. Use the `workflow_call` trigger and add an input `numbers` of type `string` (JSON array encoded as a string). The input should have a default of `'[1,2,3]'`.
3. The workflow should run a job `summarize` which:
- checks out the repository
- calls the composite action `./.github/actions/summary-action` with the provided input
- formats the JSON summary into a concise human-readable string
- publishes both the JSON `summary` and the human-readable `summary_text` as job outputs
4. The workflow should expose outputs that map to the job outputs.

<details>
<summary>Solution: reusable.yml</summary>

```yaml
name: Reusable summary workflow

on:
workflow_call:
inputs:
numbers:
description: 'JSON array of numbers as a string'
type: string
required: true
default: '[1,2,3]'
outputs:
summary:
description: 'Summary produced by the composite action (JSON)'
value: ${{ jobs.summarize.outputs.summary }}
summary_text:
description: 'Human-readable summary text'
value: ${{ jobs.summarize.outputs.summary_text }}

jobs:
summarize:
runs-on: ubuntu-latest
outputs:
summary: ${{ steps.call-summary.outputs.summary }}
summary_text: ${{ steps.format.outputs.summary_text }}
steps:
- uses: actions/checkout@v4

- name: Call summary composite action
id: call-summary
uses: ./.github/actions/summary-action
with:
numbers: ${{ inputs.numbers }}

- name: Format summary (create human-readable text)
id: format
shell: bash
run: |
printf '%s' '${{ steps.call-summary.outputs.summary }}' > summary.json || true
echo "summary_text=$(cat summary.json)" >> $GITHUB_OUTPUT

python3 ./ci/format_summary.py summary.json > summary_text.txt
echo "summary_text=$(cat summary_text.txt)" >> $GITHUB_OUTPUT
```

</details>

## Create a consumer workflow

1. Create `.github/workflows/use-reusable.yml` with a `workflow_dispatch` trigger.
2. Add a job `call-reusable` that uses the reusable workflow `./.github/workflows/reusable.yml` and passes a custom `numbers` value.
3. Add a second job `show-summary` that depends on `call-reusable` and prints the outputs.

<details>
<summary>Solution: use-reusable.yml (consumer)</summary>

```yaml
name: Use reusable summary

on: [workflow_dispatch]

jobs:
call-reusable:
uses: ./.github/workflows/reusable.yml
with:
numbers: '[5, 15, 25, 35, 45]'

show-summary:
runs-on: ubuntu-latest
needs: [call-reusable]
steps:
- name: Print reusable workflow outputs
run: |
echo "Reusable summary (JSON): ${{ needs.call-reusable.outputs.summary }}"
echo "Reusable summary (text): ${{ needs.call-reusable.outputs.summary_text }}"
```

</details>

## Try it

- Make sure `ci/print_summary.py` is executable (if running on a GitHub runner the `python3` call will work).
- Commit the `action.yml` for the composite action, the `reusable.yml` workflow, and the consumer workflow.
- Dispatch `use-reusable.yml` from the GitHub Actions UI and inspect the `show-summary` job logs to see the outputs.

## Notes and tips

- The composite action uses stdin in the example to keep wiring trivial.
- In real actions you might want to validate inputs more strictly and avoid writing secrets to disk.
- This example shows how composite actions and reusable workflows can be composed to build small, testable building blocks.