Your team runs the same sequence of commands in every workflow: checkout code, install dependencies, build, run tests. You're duplicating this across 10 workflows. It's a maintenance nightmare: any change to the build process requires updating 10 files. You want a single source of truth.
Use a composite action. Create `.github/actions/build-test/action.yml`: `name: 'Build and Test'`, `runs: composite`, then list the steps. Your workflow calls it: `uses: ./.github/actions/build-test@main`. Composite actions are run in the workflow's job context (same as inline steps), so they have fast execution and full access to the job's environment. Benefits: (1) DRY principle—one source of truth, (2) versioning: tag the action, services can pin to specific versions, (3) easier testing: test the action independently. Inputs: add `inputs: node-version: default: '18'` so callers can customize. Outputs: define `outputs: build-dir: ${{ steps.build.outputs.dir }}` to pass data back to the caller. Composite actions are local to your repo (run in the repo context), unlike JavaScript/Docker actions which are more isolated. Use composite for simple orchestration; use JavaScript/Docker for complex logic or external tool dependencies.
Follow-up: How would you add error handling and rollback logic to a composite action?
You created a composite action that runs 5 steps. One step fails. The job stops, but steps 1-3 already ran (and modified the environment). The workflow tries to re-run and fails at a different step due to the modified state. How do you make composite actions idempotent?
Idempotency means re-running the action produces the same result regardless of prior state. Best practices: (1) Check preconditions: before modifying state, verify it's safe. E.g., before installing packages, check if they're already installed. (2) Use `continue-on-error: true` on non-critical steps. (3) Clean up before starting: if a step modifies `/tmp/build`, remove `/tmp/build` at the start so prior runs don't interfere. (4) Make steps reversible: if a step sets env vars, unset them in a `finally` block. (5) Avoid side effects: if a step increments a counter or appends to a file, test if the value is already there before modifying. (6) Document assumptions: state "this action assumes a clean checkout; don't run it twice on the same directory." (7) Use transactions where possible: group related changes so they either all succeed or all fail—avoid partial states. (8) For critical operations, use `set -e` (bash) to fail fast and `trap` to clean up. Example: if a step downloads a file, and another step processes it, ensure both succeed together or neither runs.
Follow-up: Design a composite action that guarantees idempotency even if called multiple times in the same job.
You created a composite action that calls other GitHub Actions (e.g., `actions/checkout@v4`). When you try to use it, you get an error: "Composite actions cannot use 'uses' inside steps. Only JavaScript and Docker actions can invoke other actions."
This is a composite action limitation: they can't use `uses:` steps, only `run:` steps. Workarounds: (1) Restructure as a JavaScript action: convert the composite action to a JavaScript action (Node.js) that can call other actions. JavaScript actions have more power but require writing Node.js code. (2) Use a reusable workflow instead: reusable workflows CAN use other actions. Instead of a composite action, create `.github/workflows/build.yml` as a reusable workflow that callers invoke via `jobs: build: uses:`. (3) Flatten dependencies: if your composite action needs `checkout@v4`, instruct callers to call it first, then call the composite: step 1 = `uses: actions/checkout@v4`, step 2 = `uses: ./.github/actions/build`. (4) Inline the action's logic: if the nested action is simple (e.g., setup-node), copy its logic into the composite action using `run:` commands. (5) For your specific case (calling actions inside), recommend: use a reusable workflow which supports `uses:` natively. Reusable workflows are designed for orchestration, composite actions for simple step sequences.
Follow-up: When would you use a composite action vs. a reusable workflow?
Your composite action logs sensitive information (API keys, tokens) during execution. The logs are part of the job's output, visible to anyone with repo access. You need to mask this data so it doesn't appear in logs.
Use GitHub's output masking: (1) In your composite action step, before outputting data, call `echo "::add-mask::[value]"`. This tells GitHub to mask that value in all subsequent logs. Example: `echo "::add-mask::my-secret-key"`; now, if the key appears in logs, GitHub replaces it with `***`. (2) For outputs: if a composite action outputs a secret via `echo "key=value >> $GITHUB_OUTPUT"`, the caller can't see the value in logs. However, the caller gets it in the workflow context, so they can use it programmatically without exposure. (3) Better practice: don't log secrets at all. If an action needs to debug with secrets, write to a separate secure log that's not included in job output. (4) Use GitHub Secrets: if the action reads a secret (e.g., `${{ secrets.API_KEY }}`), GitHub automatically masks it in logs. (5) For sensitive outputs from your action, mark them as secrets: `outputs: token: { description: 'Bearer token' }` and in the calling workflow, retrieve it carefully (don't echo it). (6) Implement a pre-execution check: audit the composite action for any logging that might expose secrets. Add a comment: "WARNING: This action logs API responses; ensure no secrets are present."
Follow-up: How would you implement an automatic secret detection system for composite actions?
You created a composite action that runs on Linux and Windows. A step uses `bash` syntax (e.g., `$HOME`, pipes). When the action runs on Windows, the shell defaults to `cmd.exe`, and the step fails. How do you ensure cross-platform compatibility?
Specify the shell explicitly: (1) In your composite action step, add `shell: bash` to force bash, even on Windows. GitHub provides bash on all runners (via Git Bash on Windows). Example: `- run: echo $HOME | shell: bash`. (2) For Windows-specific logic, use `shell: powershell` or `shell: pwsh` (PowerShell Core, cross-platform). (3) Avoid shell-specific syntax: don't use bash/cmd features. Write portable scripts. (4) Better: write the logic in a language agnostic to the shell (Python, Node.js). Example: `- run: python scripts/build.py` works identically on all platforms. (5) For conditional platform logic: `if: runner.os == 'Windows'` or `runner.os == 'Linux'`. Different steps run on different platforms. (6) Test on all platforms: composite actions should be tested on Windows, Linux, macOS in your CI. If you only test on Linux, cross-platform issues won't surface until production. (7) Document shell requirements: in the action's README, state "Requires bash (available on all GitHub Actions runners)" or "Platform-specific: see instructions for Windows/Linux."
Follow-up: Design a composite action that works identically on Windows, macOS, and Linux.
Your composite action defines an input: `inputs: version: default: '1.0'`. A workflow calls it without providing the input, so it uses the default. Later, you update the action to change the default to `2.0`. The workflow that didn't explicitly set the version now gets `2.0`, changing behavior unexpectedly. Should you have versioned the input differently?
This is a breaking change without versioning. Best practices: (1) Never change defaults of existing inputs—callers might depend on old behavior. Instead, deprecate: add a new input (`version: 2.0, deprecated: true, use new-version instead`). (2) Version your actions: tag releases (v1, v2). If you want to change defaults, create a new major version: v1 keeps old defaults, v2 has new defaults. Callers explicitly reference versions: `uses: ./.github/actions/build@v1` or `@v2`. (3) For major changes, create a migration guide: "Version 2.0 changes the default version from 1.0 to 2.0. To opt out, pass `version: 1.0`." (4) Deprecation period: announce in the action's changelog: "Version 1.5 deprecates the old default; Version 2.0 removes it. Upgrade by 2025-12-31." (5) For frequently-changed inputs, consider making them explicitly required (no defaults) so callers must think about the value. This prevents silent behavior changes. (6) Document breaking changes: if you must change a default, include in the release notes why—give context so callers understand the impact.
Follow-up: Design a versioning strategy for composite actions that prevents breaking changes.
Your composite action takes 20 inputs (many optional). Callers are confused about which inputs are required, what they do, and what values to pass. The action's `action.yml` is hard to read. You want better documentation and discoverability.
Structure inputs clearly: (1) Group related inputs: `inputs: build_config_dir: description: 'Path to build config', build_config_format: description: 'Format: yaml or json'`. Add comments grouping them: `# Build Configuration` above the group. (2) Use required flag: `inputs: version: required: true description: 'Node version'`. This signals that this input MUST be provided. (3) Provide examples: in descriptions, include examples: `description: 'Node version (e.g., 18, 20.0.0, lts)'`. (4) Add deprecation markers: if an input is no longer used, mark it: `description: '[DEPRECATED - use new-option] Old input description'`. (5) Create a README.md next to action.yml documenting the action thoroughly. Include: purpose, inputs/outputs with examples, usage, troubleshooting. (6) Generate documentation automatically: use a script to parse action.yml and generate a markdown table of all inputs—keeps docs in sync. (7) For complex actions (>10 inputs), consider breaking it into smaller, focused actions that compose together. This reduces cognitive load on callers.
Follow-up: How would you auto-generate comprehensive documentation from an action.yml file?
You have a composite action used by 50 workflows. You need to add a new step to the action. This new step takes 5 minutes and runs on every call. For most workflows, this new step is unnecessary. How do you optionally enable it?
Add a conditional input: (1) Define an input: `inputs: enable_new_feature: type: boolean default: false`. (2) In the composite action, make the new step conditional: `- run: ./new-feature.sh if: inputs.enable_new_feature == 'true'`. (3) Callers opt-in: `uses: ./.github/actions/build@v1 with: enable_new_feature: true`. (4) By default, the new step is skipped, so existing workflows are unaffected. (5) For backward compatibility, default to false. Once the feature matures and is needed by most callers, consider flipping the default to true (with deprecation notice). (6) Alternative: tag the new step with a feature flag environment variable. Callers set `env: ENABLE_NEW_FEATURE: true` to opt-in. This is more flexible for testing. (7) Document: "New optional feature in v1.1: X. Enable with `enable_new_feature: true`. This adds 5 minutes to build time. Bug reports welcome." (8) For complex optional logic, consider an `extensions` input that accepts a list of optional modules to enable: `extensions: [feature-a, feature-b]`.
Follow-up: Design a composite action plugin system where callers can enable/disable optional features.