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
156 changes: 156 additions & 0 deletions .github/workflows/test-multibundle.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
name: 'Test: Multi-bundle restore'

on:
workflow_dispatch:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
make-bundles:
name: 'Pack bundle (${{ matrix.org }})'
runs-on: ubuntu-latest
strategy:
matrix:
org: [alpha, beta, gamma]
steps:
- uses: actions/checkout@v4

- name: Create throwaway APM project
run: |
PROJ=/tmp/apm-test-${{ matrix.org }}
mkdir -p "$PROJ"

# Each matrix replica installs the same public sample package.
# The point of this test is to validate the multi-bundle LOOP
# (N pack jobs -> N artifacts -> N unpacks into one workspace),
# not per-org distinctness (that is verified by microsoft/apm#982
# against real Apps with genuinely distinct deps -- something
# apm-action CI cannot easily mirror without a fleet of test
# packages). Identical bundles also exercise the same-SHA
# collision path -- restore should succeed without warnings.
cat > "$PROJ/apm.yml" <<'YAML'
name: test-${{ matrix.org }}
version: 1.0.0
description: throwaway fixture for multi-bundle CI
dependencies:
apm:
- microsoft/apm-sample-package
mcp: []
YAML

- name: Pack bundle
uses: ./
with:
working-directory: /tmp/apm-test-${{ matrix.org }}
pack: 'true'
archive: 'true'

- name: Upload bundle artifact
uses: actions/upload-artifact@v4
with:
name: apm-test-${{ matrix.org }}
path: /tmp/apm-test-${{ matrix.org }}/build/*.tar.gz
if-no-files-found: error

restore-bundles:
name: 'Restore 3 bundles'
needs: make-bundles
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Download all test bundles
uses: actions/download-artifact@v4
with:
pattern: apm-test-*
path: /tmp/bundles

- name: Generate bundle list file
run: |
find /tmp/bundles -name '*.tar.gz' | sort > /tmp/bundle-list.txt
echo '--- Bundle list ---'
cat /tmp/bundle-list.txt
echo '---'
test -s /tmp/bundle-list.txt

- name: Restore multi-bundle
id: restore
uses: ./
with:
bundles-file: /tmp/bundle-list.txt
working-directory: /tmp/restore-target

- name: Assert sample package files landed
run: |
# microsoft/apm-sample-package is the same dep across all 3 orgs;
# whatever it ships should appear in restore-target/.github after
# at least one unpack succeeds. Just verify the deployment dir
# is non-empty (each release of the sample package may rename
# individual files, so a content-agnostic check is more durable).
if [ ! -d /tmp/restore-target/.github ]; then
echo "FAIL: /tmp/restore-target/.github does not exist"
ls -laR /tmp/restore-target || true
exit 1
fi
FILE_COUNT=$(find /tmp/restore-target/.github -type f | wc -l)
if [ "$FILE_COUNT" -lt 1 ]; then
echo "FAIL: no files deployed under /tmp/restore-target/.github"
ls -laR /tmp/restore-target || true
exit 1
fi
echo "OK: $FILE_COUNT files deployed under /tmp/restore-target/.github"
find /tmp/restore-target/.github -type f | head -20

- name: Assert bundles-restored output
run: |
RESTORED='${{ steps.restore.outputs.bundles-restored }}'
if [ "$RESTORED" != "3" ]; then
echo "FAIL: expected bundles-restored=3, got '$RESTORED'"
exit 1
fi
echo "OK: bundles-restored=3"

# NOTE on what this CI does and does NOT prove:
# - PROVES: the multi-bundle LOOP works (3 separate pack artifacts
# each unpack successfully into one shared workspace, no errors,
# bundles-restored output is correct, collision-policy banner
# fires).
# - DOES NOT PROVE: distinct-content merge across N orgs. apm
# bundle only includes files attributable to dependencies in
# apm.yml, so we cannot inject per-replica marker files into the
# bundle without having N genuinely distinct test packages.
# The real distinct-content / per-App scenario is end-to-end
# tested by microsoft/apm#982 against real GitHub Apps.

reject-traversal:
name: 'Negative test: bundles-file rejects .. traversal'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Generate bundles-file with rejected '..' segment
run: |
mkdir -p /tmp/neg-bundles
cat > /tmp/neg-bundle-list.txt <<'EOF'
../escape.tar.gz
EOF

- name: Restore must FAIL on traversal
id: restore-neg
continue-on-error: true
uses: ./
with:
bundles-file: /tmp/neg-bundle-list.txt
working-directory: /tmp/neg-restore-target

- name: Assert step failed (traversal rejected)
run: |
if [ "${{ steps.restore-neg.outcome }}" != "failure" ]; then
echo "FAIL: expected restore step to fail on '..' traversal, got outcome=${{ steps.restore-neg.outcome }}"
exit 1
fi
echo "OK: '..' traversal in bundles-file was rejected as required"
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,35 @@ Restore primitives from a bundle. The action installs APM (cached across runs) a
bundle: './*.tar.gz'
```

<a id="multi-bundle-restore"></a>
### Multi-bundle restore (multi-org / multi-app)

**Why:** when you fan out a `pack` job across N GitHub Apps (or N orgs, or N teams) you end up with N separate bundle artifacts. Without `bundles-file`, the consumer job has to call `microsoft/apm-action@v1` N times in sequence, which adds latency and obscures which install came from which source. `bundles-file` lets a single restore step merge all N bundles into one workspace in caller-specified order. See [issue #29](https://github.com/microsoft/apm-action/issues/29) for the full rationale and diagrams.

**Backward compatibility:** existing single-`bundle` callers are unaffected. `bundles-file` is a new opt-in input; `pack`, `bundle`, and `bundles-file` are mutually exclusive (the action errors if more than one is set).

```yaml
# In a downstream job that consumes all bundles:
- uses: actions/download-artifact@v4
with:
pattern: apm-*
path: /tmp/bundles

- run: find /tmp/bundles -name '*.tar.gz' | sort > /tmp/bundle-list.txt

- uses: microsoft/apm-action@v1
id: restore
with:
bundles-file: /tmp/bundle-list.txt
working-directory: /tmp/agent-workspace

- run: echo "Merged ${{ steps.restore.outputs.bundles-restored }} bundles into the workspace"
```

The `bundles-restored` output reports the integer count of bundles successfully merged, which is convenient for assertions and logging in downstream steps.

**Collision policy:** bundles are applied in list order; on file conflicts, later bundles overwrite earlier bundles. The action logs an explicit warning naming the bundle count before the restore loop begins, so the policy is never silent. Per-file SHA-aware collision detection is planned for v1.6.0.

### Cross-job artifact workflow

Pack once, restore everywhere — identical primitives across all consumer jobs.
Expand Down Expand Up @@ -153,7 +182,7 @@ For cross-org private repos, pass a PAT with broader scope via the `github-token
github-token: ${{ secrets.APM_PAT }}
```

For multi-org or multi-platform scenarios, use the `env:` block for full control. An explicit `GITHUB_APM_PAT` in `env:` always wins over the auto-forwarded value:
For multi-org or multi-platform scenarios, use the `env:` block for full control. An explicit `GITHUB_APM_PAT` in `env:` always wins over the auto-forwarded value. (For the matrix-based fan-out pattern that pairs one App per matrix replica with [`bundles-file:`](#multi-bundle-restore), see [issue #29](https://github.com/microsoft/apm-action/issues/29).)

```yaml
# Multi-org / multi-platform: full control via env block
Expand All @@ -180,6 +209,7 @@ For multi-org or multi-platform scenarios, use the `env:` block for full control
| `compile` | No | `false` | Run `apm compile` after install to generate AGENTS.md |
| `pack` | No | `false` | Pack a bundle after install (produces `.tar.gz` by default) |
| `bundle` | No | | Restore from a bundle (local path or glob). Installs APM and unpacks via `apm unpack` (verified). |
| `bundles-file` | No | | Path to a UTF-8 text file with one bundle path per line. Restores N bundles into a single workspace in caller-specified order (last wins on collisions). Mutually exclusive with `pack` and `bundle`. |
| `target` | No | | Bundle target: `copilot`, `vscode`, `claude`, or `all` (used with `pack: true`) |
| `archive` | No | `true` | Produce `.tar.gz` instead of directory (used with `pack: true`) |
| `audit-report` | No | | Generate a SARIF audit report (hidden Unicode scanning). `apm install` already blocks critical findings; this adds reporting for Code Scanning and a markdown summary in `$GITHUB_STEP_SUMMARY`. Set to `true` for default path, or provide a custom path. |
Expand All @@ -192,6 +222,7 @@ For multi-org or multi-platform scenarios, use the `env:` block for full control
| `primitives-path` | Path where agent primitives were deployed (`.github`) |
| `bundle-path` | Path to the packed bundle (only set in pack mode) |
| `audit-report-path` | Path to the generated SARIF audit report (if `audit-report` was set) |
| `bundles-restored` | Number of bundles successfully restored (multi-bundle mode only) |

## Third-Party Dependencies

Expand Down
13 changes: 13 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ inputs:
description: 'Restore from a bundle (local path or glob pattern). Skips APM installation entirely.'
required: false
default: ''
bundles-file:
description: |
Path to a UTF-8 text file with one bundle path per line (paths must end
in '.tar.gz'). Lines starting with '#' are comments; blank lines are
ignored. Glob patterns are NOT expanded -- generate the list yourself
with 'find ... | sort' or equivalent.
Bundles are restored in caller-specified order (later bundles win on
file collisions; the action emits a warning before the loop runs so
the policy is explicit). Mutually exclusive with 'pack' and 'bundle'.
required: false
default: ''
target:
description: 'Bundle target: copilot, vscode, claude, or all (used with pack: true)'
required: false
Expand All @@ -64,6 +75,8 @@ outputs:
description: 'Path to the packed bundle (only set in pack mode)'
audit-report-path:
description: 'Path to the generated SARIF audit report, if audit-report was enabled'
bundles-restored:
description: 'Number of bundles successfully restored (multi-bundle mode only).'

runs:
using: 'node24'
Expand Down
Loading
Loading