diff --git a/.github/workflows/action-integration-test.yml b/.github/workflows/action-integration-test.yml new file mode 100644 index 0000000..6ffaafb --- /dev/null +++ b/.github/workflows/action-integration-test.yml @@ -0,0 +1,62 @@ +name: Action Integration Test + +on: + pull_request: + workflow_dispatch: + inputs: + ref: + description: 'Optional branch, tag, or SHA to checkout for testing' + required: false + default: 'main' + push: + branches: + - 'main' + +jobs: + test-action: + name: Test composite action behavior + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + case: + - name: clean notebook should pass + path: tests/e2e_notebooks/test_nochange.ipynb + expect_failure: false + - name: clean notebook with parentheses in filename should pass + path: tests/e2e_notebooks/test (nochange).ipynb + expect_failure: false + - name: dirty notebook with space in filename should fail + path: tests/e2e_notebooks/test metadata.ipynb + expect_failure: true + - name: dirty notebook should fail + path: tests/e2e_notebooks/test_metadata.ipynb + expect_failure: true + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + + - name: Run action for ${{ matrix.case.name }} + id: run_case + continue-on-error: true + uses: ./ + with: + paths: | + ${{ matrix.case.path }} + + - name: Assert expected outcome + if: always() + shell: bash + run: | + expected_outcome="${{ matrix.case.expect_failure && 'failure' || 'success' }}" + actual_outcome="${{ steps.run_case.outcome }}" + + if [ "$actual_outcome" != "$expected_outcome" ]; then + echo "Case: ${{ matrix.case.name }}" + echo "Path: ${{ matrix.case.path }}" + echo "Expected outcome: $expected_outcome" + echo "Actual outcome: $actual_outcome" + exit 1 + fi diff --git a/README.md b/README.md index cac2b0f..fdd737f 100644 --- a/README.md +++ b/README.md @@ -591,7 +591,7 @@ All inputs are optional and have sensible defaults: | Input | Description | Default | | ----- | ----------- | ------- | | `python-version` | Python version to use (supports versions, ranges, or "3.x") | `'3.x'` | -| `paths` | Space-separated list of paths to check (supports wildcards) | `'**/*.ipynb'` | +| `paths` | Newline-separated list of paths to check (supports wildcards) | `'**/*.ipynb'` | | `extra-keys` | Extra metadata keys to strip (space-separated) | `''` | | `keep-output` | Keep output in notebooks | `'false'` | | `keep-count` | Keep execution counts | `'false'` | @@ -605,7 +605,9 @@ All inputs are optional and have sensible defaults: - name: Check notebooks are stripped uses: kynan/nbstripout@main with: - paths: 'notebooks/*.ipynb examples/*.ipynb' + paths: | + notebooks/*.ipynb + examples/*.ipynb ``` #### Strip extra metadata keys @@ -641,7 +643,8 @@ All inputs are optional and have sensible defaults: - name: Check notebooks are stripped uses: kynan/nbstripout@main with: - paths: 'notebooks/**/*.ipynb' + paths: | + notebooks/**/*.ipynb keep-count: 'true' extra-keys: 'metadata.widgets cell.metadata.tags' ``` diff --git a/action.yml b/action.yml index ef7a7c7..35e78b4 100644 --- a/action.yml +++ b/action.yml @@ -6,7 +6,7 @@ inputs: required: false default: '3.x' paths: - description: 'Space-separated list of paths to check (supports wildcards)' + description: 'Newline-separated list of paths to check (supports wildcards)' required: false default: '**/*.ipynb' extra-keys: @@ -33,63 +33,69 @@ runs: with: python-version: ${{ inputs.python-version }} pip-install: 'nbstripout' - - - name: Find notebook files - id: find-notebooks - shell: bash - run: | - # Find all notebooks matching the patterns - notebooks="" - for pattern in ${{ inputs.paths }}; do - found=$(find . -path "./$pattern" -type f 2>/dev/null || true) - if [ -n "$found" ]; then - notebooks="$notebooks $found" - fi - done - - if [ -z "$notebooks" ]; then - echo "No notebook files found" - else - echo "Found notebooks: $notebooks" - echo "notebooks=$notebooks" >> $GITHUB_OUTPUT - fi - + - name: Check notebooks are stripped - if: steps.find-notebooks.outputs.notebooks != '' shell: bash + env: + INPUT_PATHS: ${{ inputs.paths }} + INPUT_EXTRA_KEYS: ${{ inputs.extra-keys }} + INPUT_KEEP_OUTPUT: ${{ inputs.keep-output }} + INPUT_KEEP_COUNT: ${{ inputs.keep-count }} + INPUT_STRIP_INIT_CELLS: ${{ inputs.strip-init-cells }} run: | - # Build nbstripout command with options - cmd="nbstripout --verify" - - if [ "${{ inputs.keep-output }}" = "true" ]; then - cmd="$cmd --keep-output" + # Build nbstripout args as an array (bash 3.1+) + args=(--verify) + + if [ "$INPUT_KEEP_OUTPUT" = "true" ]; then + args+=(--keep-output) + fi + + if [ "$INPUT_KEEP_COUNT" = "true" ]; then + args+=(--keep-count) fi - - if [ "${{ inputs.keep-count }}" = "true" ]; then - cmd="$cmd --keep-count" + + if [ "$INPUT_STRIP_INIT_CELLS" = "true" ]; then + args+=(--strip-init-cells) fi - - if [ "${{ inputs.strip-init-cells }}" = "true" ]; then - cmd="$cmd --strip-init-cells" + + if [ -n "$INPUT_EXTRA_KEYS" ]; then + args+=(--extra-keys "$INPUT_EXTRA_KEYS") fi - - if [ -n "${{ inputs.extra-keys }}" ]; then - cmd="$cmd --extra-keys '${{ inputs.extra-keys }}'" + + # Use Python for file discovery: correct ** glob handling on all platforms + # Outputs null-delimited paths and deduplicates overlapping patterns. + # INPUT_PATHS is quoted to prevent bash from expanding globs before Python sees them. + notebook_list="${RUNNER_TEMP:-/tmp}/nbstripout_filelist.txt" + + python3 - "$INPUT_PATHS" << 'PYTHON' > "$notebook_list" + import sys + from pathlib import Path + + raw_input = sys.argv[1] + patterns = [stripped for line in raw_input.splitlines() if (stripped := line.strip())] + seen = set() + for pat in patterns: + for p in sorted(Path(".").glob(pat)): + rp = str(p) + if p.is_file() and rp not in seen: + seen.add(rp) + sys.stdout.write(rp + "\0") + PYTHON + + if [ ! -s "$notebook_list" ]; then + echo "No notebook files found matching: $INPUT_PATHS" + exit 0 fi - - # Check each notebook - failed=0 - for notebook in ${{ steps.find-notebooks.outputs.notebooks }}; do - echo "Checking $notebook..." - if ! eval "$cmd $notebook"; then - echo "❌ $notebook has output that should be stripped" - failed=1 - else - echo "✅ $notebook is properly stripped" - fi - done - - if [ $failed -eq 1 ]; then + + # Count null-delimited entries (POSIX-compatible, works on macOS and Linux) + notebook_count=$(tr -cd '\0' < "$notebook_list" | wc -c | tr -d ' ') + echo "Found $notebook_count notebook(s) to check." + + # xargs -0 reads null-delimited paths (handles any filename characters) + # and batches automatically for ARG_MAX safety on large repos + if xargs -0 nbstripout "${args[@]}" < "$notebook_list"; then + echo "All notebooks are properly stripped! ✅" + else echo "" echo "================================================" echo "Some notebooks have output that should be stripped." @@ -98,5 +104,3 @@ runs: echo "================================================" exit 1 fi - - echo "All notebooks are properly stripped! ✅" diff --git a/tests/e2e_notebooks/test (nochange).ipynb b/tests/e2e_notebooks/test (nochange).ipynb new file mode 100644 index 0000000..605c0c7 --- /dev/null +++ b/tests/e2e_notebooks/test (nochange).ipynb @@ -0,0 +1,61 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This notebook should not be changed by nbstripout." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "outputs": [], + "source": [ + "1+1 # This cell has `\"init_cell:\" true`" + ] + }, + { + "cell_type": "code", + "metadata": { + "keep_output": true + }, + "outputs": [ + { + "data": { + "text/plain": [ + "4" + ] + }, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "2+2 # This cell has `\"keep_output:\" true`" + ] + } + ], + "metadata": { + "celltoolbar": "Edit Metadata", + "kernelspec": { + "display_name": "Python 2", + "language": "python", + "name": "python2" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.11" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/tests/e2e_notebooks/test metadata.ipynb b/tests/e2e_notebooks/test metadata.ipynb new file mode 100644 index 0000000..613ab73 --- /dev/null +++ b/tests/e2e_notebooks/test metadata.ipynb @@ -0,0 +1,101 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This notebook tests that cells with either `\"keep_output\": true` or `\"init_cell\": true` are not stripped." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "collapsed": false, + "init_cell": true + }, + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "1+1 # This cell has `\"init_cell:\" true`" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "collapsed": false, + "keep_output": true + }, + "outputs": [ + { + "data": { + "text/plain": [ + "4" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "2+2 # This cell has `\"keep_output:\" true`" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "text/plain": [ + "6" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "3+3" + ] + } + ], + "metadata": { + "celltoolbar": "Edit Metadata", + "kernelspec": { + "display_name": "Python 2", + "language": "python", + "name": "python2" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.11" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 321e64c..2e494c0 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -21,6 +21,7 @@ ('test_max_size.ipynb', 'test_max_size.ipynb.expected_sequential_id', ['--max-size', '50']), ('test_empty_metadata.ipynb', 'test_empty_metadata.ipynb.expected', []), ('test_metadata.ipynb', 'test_metadata.ipynb.expected', []), + ('test metadata.ipynb', 'test_metadata.ipynb.expected', []), ( 'test_metadata.ipynb', 'test_metadata_extra_keys.ipynb.expected', @@ -57,6 +58,7 @@ ('test_metadata.ipynb', [], True), ('test_zeppelin.zpln', ['--mode', 'zeppelin'], True), ('test_nochange.ipynb', [], False), + ('test (nochange).ipynb', [], False), ] ERR_OUTPUT_CASES = [