diff --git a/README.md b/README.md index 4de8a568..9c880ecd 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/ci/format_summary.py b/ci/format_summary.py new file mode 100644 index 00000000..63d0b9e4 --- /dev/null +++ b/ci/format_summary.py @@ -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 ", 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() \ No newline at end of file diff --git a/ci/print_summary.py b/ci/print_summary.py new file mode 100644 index 00000000..c4df0f9a --- /dev/null +++ b/ci/print_summary.py @@ -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() diff --git a/labs/composite-action.md b/labs/composite-action.md new file mode 100644 index 00000000..736dc445 --- /dev/null +++ b/labs/composite-action.md @@ -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: + +
+ Solution + + ```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 + ``` + +
+ +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): + +
+ Solution + + ```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 }}" + ``` + +
+ +## 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. diff --git a/labs/reusable.md b/labs/old-labs/reusable.md similarity index 100% rename from labs/reusable.md rename to labs/old-labs/reusable.md diff --git a/labs/reusable-workflow.md b/labs/reusable-workflow.md new file mode 100644 index 00000000..d2012843 --- /dev/null +++ b/labs/reusable-workflow.md @@ -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. + +
+ Solution: reusable.yml + + ```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 + ``` + +
+ +## 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. + +
+ Solution: use-reusable.yml (consumer) + + ```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 }}" + ``` + +
+ +## 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.