From f6cf89b03f4d80a18869f9d6df64b659dd91fbec Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:20:04 +0200 Subject: [PATCH 1/3] feat: add update input to refresh dependency refs Add an `update` boolean input that runs `apm update --yes` in place of `apm install` in the project-install flow. It re-resolves branch/tag dependency refs to their latest matching commit and rewrites the lockfile, then proceeds with the normal post-install steps (audit-report, compile, script, pack). This gives a first-class, discoverable surface for the auto-update workflow described in microsoft/apm-action#46, replacing the hand-rolled `script: "apm update --yes"` workaround. The default (false) keeps the reproducible install-from-lockfile behaviour. `update` is mutually exclusive with the no-install / from-scratch modes (isolated, setup-only, bundle, bundles-file, mode), each rejected with a clear consolidated error. Closes #46 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 5 ++ README.md | 35 +++++++++ action.yml | 13 ++++ dist/index.js | 37 ++++++++- dist/runner.d.ts | 1 + src/__tests__/runner.test.ts | 145 +++++++++++++++++++++++++++++++++++ src/runner.ts | 35 ++++++++- 7 files changed, 267 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba78656..e343d9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The floating `v1` tag tracks the latest `1.x` release. Consumers pinning ## [Unreleased] +### Added + +- **`update` input to refresh branch/tag dependency refs** ([microsoft/apm-action#46]). When `update: 'true'`, the action runs `apm update --yes` in place of `apm install`: it re-resolves every branch/tag dependency to its latest matching commit, rewrites `apm.lock.yaml`, and then proceeds with the normal post-install steps (`audit-report`, `compile`, `script`, `pack`). The default (`false`) keeps the reproducible install-from-lockfile behaviour. Closes the gap where branch-tracked deps (e.g. `github/awesome-copilot#main`) stayed frozen at the lockfile commit with no first-class way to refresh them — the previous workaround was a hand-rolled `script: "apm update --yes"`. Mutually exclusive with `isolated`, `setup-only`, `bundle`, `bundles-file`, and `mode`. + ## [1.9.1] - 2026-05-19 ### Fixed @@ -88,6 +92,7 @@ APM v0.12.3 made harness detection strict (no more silent default-to-copilot). E [#33]: https://github.com/microsoft/apm-action/pull/33 [#34]: https://github.com/microsoft/apm-action/pull/34 +[microsoft/apm-action#46]: https://github.com/microsoft/apm-action/issues/46 [microsoft/apm#1348]: https://github.com/microsoft/apm/issues/1348 ## [1.6.0] - 2026-05-02 diff --git a/README.md b/README.md index aed9420..4e3acc6 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,40 @@ Just install the APM CLI and put it on `PATH`, like `actions/setup-node`. Run an `setup-only: true` is mutually exclusive with `pack`, `bundle`, and `bundles-file`. The action will not read `apm.yml`, run `apm install`, or deploy primitives. Sets the `apm-version` and `apm-path` outputs so downstream steps can branch on the resolved CLI. +### Update mode (refresh branch/tag refs) + +By default the action runs `apm install`, which honours `apm.lock.yaml` and pins each dependency to the exact commit recorded in the lockfile — reproducible by design. Dependencies tracked by a branch or tag (e.g. `github/awesome-copilot#main`) therefore stay frozen until the lockfile is refreshed. + +Set `update: 'true'` to run `apm update --yes` instead: it re-resolves every ref to its latest matching commit, rewrites `apm.lock.yaml`, and deploys the refreshed assets. Pair it with a PR-opening step to land the changes: + +```yaml +name: Refresh AI agent assets +on: + schedule: + - cron: '0 6 * * 1' # Monday 06:00 UTC + workflow_dispatch: + +jobs: + apm-update: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: microsoft/apm-action@v1 + with: + update: 'true' + audit-report: 'true' + - name: Open PR if changed + uses: peter-evans/create-pull-request@v6 + with: + title: 'chore: update AI agent assets' + branch: apm/auto-update +``` + +`update: true` composes with `audit-report`, `compile`, `script`, `dependencies`, and `pack` (which all run after the refresh). It is mutually exclusive with `isolated`, `setup-only`, `bundle`, `bundles-file`, and `mode`, since those either skip the project install or build from scratch with no lockfile to update. + ### Bundle format (`apm` vs `plugin`) `apm pack` supports two layouts: @@ -315,6 +349,7 @@ For multi-org or multi-platform scenarios, use the `env:` block for full control | `dependencies` | No | | YAML array of extra dependencies to install (additive to apm.yml) | | `isolated` | No | `false` | Ignore apm.yml and clear pre-existing primitive dirs — install only inline dependencies | | `compile` | No | `false` | Run `apm compile` after install to generate AGENTS.md | +| `update` | No | `false` | Run `apm update --yes` instead of `apm install`: re-resolve branch/tag refs to their latest matching commit and rewrite `apm.lock.yaml`, then run the normal post-install steps. Use in a scheduled/dispatch job that opens a PR with the refreshed assets. Mutually exclusive with `isolated`, `setup-only`, `bundle`, `bundles-file`, and `mode`. | | `pack` | No | `false` | Pack a bundle after install (produces `.tar.gz` by default) | | `bundle-format` | No | `apm` | Bundle layout when `pack: true`. `apm` produces an APM bundle (with `apm.lock.yaml` and a `.github/` tree, restorable by this action). `plugin` produces a Claude Code plugin bundle (with `plugin.json` at the root, intended for marketplace consumption). | | `setup-only` | No | `false` | Install the APM CLI and exit. No `apm.yml` is read, no `apm install` runs, no primitives are deployed. Mutually exclusive with `pack`, `bundle`, and `bundles-file`. | diff --git a/action.yml b/action.yml index 676bb6b..4add6f1 100644 --- a/action.yml +++ b/action.yml @@ -34,6 +34,19 @@ inputs: description: 'Run apm compile after install to generate AGENTS.md' required: false default: 'false' + update: + description: | + Re-resolve dependencies instead of installing from the lockfile. + When 'true', the action runs `apm update --yes` in place of + `apm install`: it refreshes branch/tag refs to their latest matching + commit and rewrites apm.lock.yaml, then proceeds with the normal + post-install steps (audit-report, compile, script, pack). Use this in + a scheduled or workflow_dispatch job that opens a PR with the updated + assets. Defaults to 'false' (install pinned versions from the lockfile, + the reproducible-by-default behaviour). Mutually exclusive with + isolated, setup-only, bundle, bundles-file, and mode. + required: false + default: 'false' pack: description: 'Pack a bundle after install. Format is controlled by `bundle-format` (default: apm). Archived output is .zip by default (apm 0.20+); older CLIs emit .tar.gz. Both are accepted on restore.' required: false diff --git a/dist/index.js b/dist/index.js index 877fac1..e3fb5ac 100644 --- a/dist/index.js +++ b/dist/index.js @@ -42520,6 +42520,7 @@ function parseMarketplacePath(raw) { * With `dependencies` input: parses YAML array, installs each as extra deps (additive to apm.yml). * With `isolated: true`: clears existing primitives, ignores apm.yml, installs only inline deps. * With `compile: true`: runs apm compile after install to generate AGENTS.md. + * With `update: true`: runs `apm update --yes` instead of `apm install`, re-resolving refs and rewriting the lockfile. * With `script` input: runs an apm script after install. * With `pack: true`: runs apm pack after install to produce a bundle. * With `bundle` input: restores from a bundle (no APM install needed). @@ -42533,6 +42534,13 @@ async function run() { const bundlesFileInput = lib_core/* getInput */.V4('bundles-file').trim(); const packInput = lib_core/* getInput */.V4('pack') === 'true'; const isolated = lib_core/* getInput */.V4('isolated') === 'true'; + // `update: true` swaps the project-install verb from `apm install` to + // `apm update --yes`, re-resolving branch/tag refs and rewriting the + // lockfile (microsoft/apm-action#46). It only makes sense in the + // default project-install flow, so it is rejected in the no-install + // modes (setup-only / bundle / bundles-file / mode) and in isolated + // mode (a from-scratch install with no pre-existing lockfile to update). + const update = lib_core/* getInput */.V4('update') === 'true'; // Default `packages` output to '[]' so downstream `fromJSON()` steps // can parse it unconditionally regardless of mode. mode: release // overwrites this with the actual JSON array of packed artifacts. @@ -42556,6 +42564,8 @@ async function run() { modeConflicts.push('bundles-file'); if (lib_core/* getInput */.V4('setup-only') === 'true') modeConflicts.push('setup-only'); + if (update) + modeConflicts.push('update'); if (modeConflicts.length > 0) { throw new Error(`mode='${modeInput}' is mutually exclusive with: ${modeConflicts.join(', ')}. ` + `mode runs a fixed pipeline; remove the conflicting flag(s) or unset mode.`); @@ -42704,6 +42714,8 @@ async function run() { conflicts.push('include-prerelease'); if (lib_core/* getInput */.V4('mode').trim()) conflicts.push('mode'); + if (update) + conflicts.push('update'); if (conflicts.length > 0) { throw new Error(`'setup-only' is mutually exclusive with: ${conflicts.join(', ')}. ` + `setup-only installs the APM CLI onto PATH and exits; remove the ` @@ -42735,6 +42747,23 @@ async function run() { throw new Error(`inputs 'pack', 'bundle', and 'bundles-file' are mutually exclusive ` + `(got: ${modeFlags.join(', ')}). Pick exactly one mode per step.`); } + // `update` mutex: it swaps the project-install verb and is meaningless + // in the no-install restore modes and in from-scratch isolated installs. + // Fail fast with a clear message rather than silently ignoring the flag. + if (update) { + const updateConflicts = []; + if (isolated) + updateConflicts.push('isolated'); + if (bundleInput) + updateConflicts.push('bundle'); + if (bundlesFileInput) + updateConflicts.push('bundles-file'); + if (updateConflicts.length > 0) { + throw new Error(`'update' is mutually exclusive with: ${updateConflicts.join(', ')}. ` + + `update re-resolves dependencies in the project-install flow; ` + + `remove the conflicting input(s) or set update: false.`); + } + } // Reject pack pass-through inputs outside pack mode early, so they // are not silently ignored in bundle / bundles-file restore paths or // in the default install flow. Matches the setup-only conflict shape. @@ -42893,10 +42922,14 @@ async function run() { await runApm(['install'], resolvedDir); } else { - // Default: install from apm.yml (if present), then add inline deps + // Default: install (or update) from apm.yml (if present), then add + // inline deps. `update: true` swaps `apm install` for `apm update + // --yes`, re-resolving branch/tag refs and rewriting the lockfile + // non-interactively (microsoft/apm-action#46). + const projectVerb = update ? ['update', '--yes'] : ['install']; const apmYmlPath = external_path_.join(resolvedDir, 'apm.yml'); if (external_fs_.existsSync(apmYmlPath) || !depsInput) { - await runApm(['install'], resolvedDir); + await runApm(projectVerb, resolvedDir); } // Install extra inline deps additively if (depsInput) { diff --git a/dist/runner.d.ts b/dist/runner.d.ts index 3f24d8d..88b11d4 100644 --- a/dist/runner.d.ts +++ b/dist/runner.d.ts @@ -5,6 +5,7 @@ * With `dependencies` input: parses YAML array, installs each as extra deps (additive to apm.yml). * With `isolated: true`: clears existing primitives, ignores apm.yml, installs only inline deps. * With `compile: true`: runs apm compile after install to generate AGENTS.md. + * With `update: true`: runs `apm update --yes` instead of `apm install`, re-resolving refs and rewriting the lockfile. * With `script` input: runs an apm script after install. * With `pack: true`: runs apm pack after install to produce a bundle. * With `bundle` input: restores from a bundle (no APM install needed). diff --git a/src/__tests__/runner.test.ts b/src/__tests__/runner.test.ts index c23ce26..30116b4 100644 --- a/src/__tests__/runner.test.ts +++ b/src/__tests__/runner.test.ts @@ -845,6 +845,151 @@ describe('run', () => { }); }); +// `apm update` support (microsoft/apm-action#46) +describe('update input', () => { + let tmpDir: string; + + beforeEach(() => { + jest.clearAllMocks(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'apm-action-update-')); + mockEnsureApmInstalled.mockResolvedValue({ resolvedVersion: '0.14.0', toolDir: '/opt/hostedtoolcache/apm/0.14.0/x64', binaryPath: '/opt/hostedtoolcache/apm/0.14.0/x64/apm' }); + mockExec.mockResolvedValue(0); + mockGetExecOutput.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('runs `apm update --yes` instead of `apm install` when update: true', async () => { + fs.writeFileSync(path.join(tmpDir, 'apm.yml'), 'name: t\nversion: 1.0.0\n'); + fs.mkdirSync(path.join(tmpDir, '.github'), { recursive: true }); + mockGetInput.mockImplementation((name: unknown) => { + switch (name) { + case 'working-directory': return tmpDir; + case 'update': return 'true'; + default: return ''; + } + }); + + await run(); + + expect(mockSetFailed).not.toHaveBeenCalled(); + const apmCalls = mockExec.mock.calls.filter(c => c[0] === 'apm'); + // `apm update --yes` was invoked, and `apm install` was not. + expect(apmCalls.some(c => Array.isArray(c[1]) && c[1][0] === 'update' && (c[1] as string[]).includes('--yes'))).toBe(true); + expect(apmCalls.some(c => Array.isArray(c[1]) && c[1][0] === 'install')).toBe(false); + }); + + it('runs `apm install` (not update) when update unset', async () => { + fs.writeFileSync(path.join(tmpDir, 'apm.yml'), 'name: t\nversion: 1.0.0\n'); + fs.mkdirSync(path.join(tmpDir, '.github'), { recursive: true }); + mockGetInput.mockImplementation((name: unknown) => { + switch (name) { + case 'working-directory': return tmpDir; + default: return ''; + } + }); + + await run(); + + expect(mockSetFailed).not.toHaveBeenCalled(); + const apmCalls = mockExec.mock.calls.filter(c => c[0] === 'apm'); + expect(apmCalls.some(c => Array.isArray(c[1]) && c[1][0] === 'install')).toBe(true); + expect(apmCalls.some(c => Array.isArray(c[1]) && c[1][0] === 'update')).toBe(false); + }); + + it('still runs compile after update when both set', async () => { + fs.writeFileSync(path.join(tmpDir, 'apm.yml'), 'name: t\nversion: 1.0.0\n'); + fs.mkdirSync(path.join(tmpDir, '.github'), { recursive: true }); + mockGetInput.mockImplementation((name: unknown) => { + switch (name) { + case 'working-directory': return tmpDir; + case 'update': return 'true'; + case 'compile': return 'true'; + default: return ''; + } + }); + + await run(); + + expect(mockSetFailed).not.toHaveBeenCalled(); + const apmCalls = mockExec.mock.calls.filter(c => c[0] === 'apm'); + expect(apmCalls.some(c => Array.isArray(c[1]) && c[1][0] === 'update')).toBe(true); + expect(apmCalls.some(c => Array.isArray(c[1]) && c[1][0] === 'compile')).toBe(true); + }); + + it('rejects update + setup-only with consolidated error', async () => { + mockGetInput.mockImplementation((name: unknown) => { + switch (name) { + case 'working-directory': return tmpDir; + case 'setup-only': return 'true'; + case 'update': return 'true'; + default: return ''; + } + }); + + await run(); + + expect(mockSetFailed).toHaveBeenCalledWith( + expect.stringContaining("'setup-only' is mutually exclusive with: update"), + ); + }); + + it('rejects update + isolated with a clear error', async () => { + fs.mkdirSync(tmpDir, { recursive: true }); + mockGetInput.mockImplementation((name: unknown) => { + switch (name) { + case 'working-directory': return tmpDir; + case 'isolated': return 'true'; + case 'dependencies': return 'microsoft/some-package'; + case 'update': return 'true'; + default: return ''; + } + }); + + await run(); + + expect(mockSetFailed).toHaveBeenCalledWith( + expect.stringContaining("'update' is mutually exclusive with: isolated"), + ); + }); + + it('rejects update + bundle with a clear error', async () => { + mockGetInput.mockImplementation((name: unknown) => { + switch (name) { + case 'working-directory': return tmpDir; + case 'bundle': return 'some-bundle.zip'; + case 'update': return 'true'; + default: return ''; + } + }); + + await run(); + + expect(mockSetFailed).toHaveBeenCalledWith( + expect.stringContaining("'update' is mutually exclusive with: bundle"), + ); + }); + + it('rejects update + mode: release with a clear error', async () => { + mockGetInput.mockImplementation((name: unknown) => { + switch (name) { + case 'working-directory': return tmpDir; + case 'mode': return 'release'; + case 'update': return 'true'; + default: return ''; + } + }); + + await run(); + + expect(mockSetFailed).toHaveBeenCalledWith( + expect.stringContaining("mode='release' is mutually exclusive with: update"), + ); + }); +}); + describe('run (restore mode)', () => { let tmpDir: string; diff --git a/src/runner.ts b/src/runner.ts index dad7d63..169e62a 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -84,6 +84,7 @@ function parseMarketplacePath(raw: string): string[] { * With `dependencies` input: parses YAML array, installs each as extra deps (additive to apm.yml). * With `isolated: true`: clears existing primitives, ignores apm.yml, installs only inline deps. * With `compile: true`: runs apm compile after install to generate AGENTS.md. + * With `update: true`: runs `apm update --yes` instead of `apm install`, re-resolving refs and rewriting the lockfile. * With `script` input: runs an apm script after install. * With `pack: true`: runs apm pack after install to produce a bundle. * With `bundle` input: restores from a bundle (no APM install needed). @@ -97,6 +98,13 @@ export async function run(): Promise { const bundlesFileInput = core.getInput('bundles-file').trim(); const packInput = core.getInput('pack') === 'true'; const isolated = core.getInput('isolated') === 'true'; + // `update: true` swaps the project-install verb from `apm install` to + // `apm update --yes`, re-resolving branch/tag refs and rewriting the + // lockfile (microsoft/apm-action#46). It only makes sense in the + // default project-install flow, so it is rejected in the no-install + // modes (setup-only / bundle / bundles-file / mode) and in isolated + // mode (a from-scratch install with no pre-existing lockfile to update). + const update = core.getInput('update') === 'true'; // Default `packages` output to '[]' so downstream `fromJSON()` steps // can parse it unconditionally regardless of mode. mode: release @@ -120,6 +128,7 @@ export async function run(): Promise { if (bundleInput) modeConflicts.push('bundle'); if (bundlesFileInput) modeConflicts.push('bundles-file'); if (core.getInput('setup-only') === 'true') modeConflicts.push('setup-only'); + if (update) modeConflicts.push('update'); if (modeConflicts.length > 0) { throw new Error( `mode='${modeInput}' is mutually exclusive with: ${modeConflicts.join(', ')}. ` @@ -262,6 +271,7 @@ export async function run(): Promise { if (core.getInput('offline') === 'true') conflicts.push('offline'); if (core.getInput('include-prerelease') === 'true') conflicts.push('include-prerelease'); if (core.getInput('mode').trim()) conflicts.push('mode'); + if (update) conflicts.push('update'); if (conflicts.length > 0) { throw new Error( `'setup-only' is mutually exclusive with: ${conflicts.join(', ')}. ` @@ -303,6 +313,23 @@ export async function run(): Promise { ); } + // `update` mutex: it swaps the project-install verb and is meaningless + // in the no-install restore modes and in from-scratch isolated installs. + // Fail fast with a clear message rather than silently ignoring the flag. + if (update) { + const updateConflicts: string[] = []; + if (isolated) updateConflicts.push('isolated'); + if (bundleInput) updateConflicts.push('bundle'); + if (bundlesFileInput) updateConflicts.push('bundles-file'); + if (updateConflicts.length > 0) { + throw new Error( + `'update' is mutually exclusive with: ${updateConflicts.join(', ')}. ` + + `update re-resolves dependencies in the project-install flow; ` + + `remove the conflicting input(s) or set update: false.`, + ); + } + } + // Reject pack pass-through inputs outside pack mode early, so they // are not silently ignored in bundle / bundles-file restore paths or // in the default install flow. Matches the setup-only conflict shape. @@ -488,10 +515,14 @@ export async function run(): Promise { await generateManifest(resolvedDir, deps, validatedTarget); await runApm(['install'], resolvedDir); } else { - // Default: install from apm.yml (if present), then add inline deps + // Default: install (or update) from apm.yml (if present), then add + // inline deps. `update: true` swaps `apm install` for `apm update + // --yes`, re-resolving branch/tag refs and rewriting the lockfile + // non-interactively (microsoft/apm-action#46). + const projectVerb = update ? ['update', '--yes'] : ['install']; const apmYmlPath = path.join(resolvedDir, 'apm.yml'); if (fs.existsSync(apmYmlPath) || !depsInput) { - await runApm(['install'], resolvedDir); + await runApm(projectVerb, resolvedDir); } // Install extra inline deps additively From a19d5e15d2bf24a78b2e1c3ae4196249d8b636a7 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Thu, 25 Jun 2026 19:29:03 +0200 Subject: [PATCH 2/3] test(ci): add end-to-end integration job for update mode Adds a `test-update` CI job that proves the new `update` input works against the real apm CLI, matching the empirical-proof pattern of the other test-* integration jobs (the existing coverage for #46 was mock-based unit tests only). The job: - seeds an install via the default verb and asserts apm.lock.yaml is written with a resolved_commit; - re-runs the action with `update: 'true'` and asserts the refresh path (`apm update --yes`) keeps a valid resolved lockfile and the deployed primitives in place -- the exact scenario from issue #46; - asserts the mutex guard rejects `update` + `setup-only` (negative test, mirroring the bundles-file traversal job). Wires `test-update` into the `release` job's needs gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 82 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f4efe2..f12fcf9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,86 @@ jobs: fi echo "✅ Primitives deployed from manifest" + # Integration: update mode -- refresh an existing install via + # `apm update --yes` (the verb swap from issue #46), then prove the + # mutex guard rejects update combined with a no-install mode. + test-update: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + + - name: Create test apm.yml + run: | + cat > apm.yml << 'EOF' + name: ci-update-test + version: 1.0.0 + target: copilot + dependencies: + apm: + - microsoft/apm-sample-package + EOF + + - name: Seed an initial install (default verb) + uses: ./ + + - name: Verify install seeded a resolved lockfile + run: | + if [ ! -f apm.lock.yaml ]; then + echo "::error::install did not create apm.lock.yaml" + exit 1 + fi + if ! grep -q 'resolved_commit:' apm.lock.yaml; then + echo "::error::lockfile has no resolved_commit after install" + exit 1 + fi + echo "Install seeded apm.lock.yaml with a resolved commit" + + - name: Run action in update mode + uses: ./ + with: + update: 'true' + + - name: Verify update refreshed the project end-to-end + run: | + # The refresh path from issue #46: 'apm update --yes' must keep a + # valid, resolved lockfile and leave the deployed primitives in + # place. A failure here means the verb swap did not run the real + # update flow successfully against the live apm CLI. + if [ ! -f apm.lock.yaml ]; then + echo "::error::update removed apm.lock.yaml" + exit 1 + fi + if ! grep -q 'resolved_commit:' apm.lock.yaml; then + echo "::error::lockfile lost resolved_commit after update" + exit 1 + fi + if ! find .agents/skills .github -type f \ + \( -name 'SKILL.md' -o -name '*.instructions.md' \ + -o -name '*.agent.md' -o -name '*.prompt.md' \) 2>/dev/null \ + | grep -q .; then + echo "::error::no primitives deployed after update" + exit 1 + fi + echo "update mode refreshed lockfile and primitives successfully" + + - name: Attempt update + setup-only -- expect failure + id: update-mutex + continue-on-error: true + uses: ./ + with: + update: 'true' + setup-only: 'true' + + - name: Assert update mutex rejected the conflicting combo + run: | + if [ "${{ steps.update-mutex.outcome }}" != "failure" ]; then + echo "::error::update + setup-only should be rejected, got ${{ steps.update-mutex.outcome }}" + exit 1 + fi + echo "update + setup-only correctly rejected by mutex guard" + # Integration: isolated mode (inline deps only, clean .github/) test-isolated: needs: build @@ -532,7 +612,7 @@ jobs: # Release: update major version tag (v1 → v1.x.x) release: - needs: [build, test-manifest, test-isolated, test-compile, test-pack, test-restore-artifact, test-restore-clean-workspace] + needs: [build, test-manifest, test-update, test-isolated, test-compile, test-pack, test-restore-artifact, test-restore-clean-workspace] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest timeout-minutes: 5 From 71f60bdec53376affd3e13828fa719aa0e43e778 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Thu, 25 Jun 2026 19:36:43 +0200 Subject: [PATCH 3/3] fix(update): fail fast when update:true but no apm.yml to refresh Addresses Copilot review on #51. Previously, when `update: 'true'` was combined with the `dependencies` input and no apm.yml was present, the project-verb dispatch condition (`fs.existsSync(apmYmlPath) || !depsInput`) was false, so the action silently skipped `apm update --yes` -- the update flag was effectively ignored, contradicting the documented verb swap. `update` is a project-manifest refresh: it re-resolves apm.yml refs and rewrites apm.lock.yaml. With no apm.yml there is nothing to refresh, so the action now fails fast with a clear, actionable error instead of no-op'ing. The `dependencies` input remains an additive inline install, not a manifest refresh. - src/runner.ts: guard rejecting update with no apm.yml (both the deps-present and no-deps paths). - src/__tests__/runner.test.ts: 2 tests (deps-present and no-deps). - action.yml + README: document the apm.yml requirement. - dist/index.js rebuilt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- action.yml | 9 +++++--- dist/index.js | 14 +++++++++++- src/__tests__/runner.test.ts | 43 ++++++++++++++++++++++++++++++++++++ src/runner.ts | 16 +++++++++++++- 5 files changed, 78 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4e3acc6..3295125 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ jobs: branch: apm/auto-update ``` -`update: true` composes with `audit-report`, `compile`, `script`, `dependencies`, and `pack` (which all run after the refresh). It is mutually exclusive with `isolated`, `setup-only`, `bundle`, `bundles-file`, and `mode`, since those either skip the project install or build from scratch with no lockfile to update. +`update: true` composes with `audit-report`, `compile`, `script`, `dependencies`, and `pack` (which all run after the refresh). It requires an `apm.yml` in the working directory to refresh and fails fast if none is present (the `dependencies` input is an additive inline install, not a manifest refresh). It is mutually exclusive with `isolated`, `setup-only`, `bundle`, `bundles-file`, and `mode`, since those either skip the project install or build from scratch with no lockfile to update. ### Bundle format (`apm` vs `plugin`) diff --git a/action.yml b/action.yml index 4add6f1..93ed094 100644 --- a/action.yml +++ b/action.yml @@ -42,9 +42,12 @@ inputs: commit and rewrites apm.lock.yaml, then proceeds with the normal post-install steps (audit-report, compile, script, pack). Use this in a scheduled or workflow_dispatch job that opens a PR with the updated - assets. Defaults to 'false' (install pinned versions from the lockfile, - the reproducible-by-default behaviour). Mutually exclusive with - isolated, setup-only, bundle, bundles-file, and mode. + assets. Requires an apm.yml in the working directory to refresh; the + action fails fast if none is present (the dependencies input is an + additive inline install, not a manifest refresh). Defaults to 'false' + (install pinned versions from the lockfile, the reproducible-by-default + behaviour). Mutually exclusive with isolated, setup-only, bundle, + bundles-file, and mode. required: false default: 'false' pack: diff --git a/dist/index.js b/dist/index.js index e3fb5ac..7414611 100644 --- a/dist/index.js +++ b/dist/index.js @@ -42926,8 +42926,20 @@ async function run() { // inline deps. `update: true` swaps `apm install` for `apm update // --yes`, re-resolving branch/tag refs and rewriting the lockfile // non-interactively (microsoft/apm-action#46). - const projectVerb = update ? ['update', '--yes'] : ['install']; const apmYmlPath = external_path_.join(resolvedDir, 'apm.yml'); + // `update` is a project-manifest refresh: it re-resolves the refs + // declared in apm.yml and rewrites apm.lock.yaml. With no apm.yml + // there is nothing to refresh, so fail fast rather than silently + // ignoring the flag -- the `dependencies` input is an additive + // inline install, not a manifest refresh. + if (update && !external_fs_.existsSync(apmYmlPath)) { + throw new Error(`'update: true' requires an apm.yml in the working directory to ` + + `refresh, but none was found at ${apmYmlPath}. 'apm update' ` + + `re-resolves apm.yml refs and rewrites apm.lock.yaml; the ` + + `'dependencies' input is an additive inline install, not a ` + + `manifest refresh. Commit an apm.yml, or remove update: true.`); + } + const projectVerb = update ? ['update', '--yes'] : ['install']; if (external_fs_.existsSync(apmYmlPath) || !depsInput) { await runApm(projectVerb, resolvedDir); } diff --git a/src/__tests__/runner.test.ts b/src/__tests__/runner.test.ts index 30116b4..43de1a3 100644 --- a/src/__tests__/runner.test.ts +++ b/src/__tests__/runner.test.ts @@ -988,6 +988,49 @@ describe('update input', () => { expect.stringContaining("mode='release' is mutually exclusive with: update"), ); }); + + it('fails fast when update: true but no apm.yml is present (deps input)', async () => { + // No apm.yml written. With inline deps present, the legacy condition + // would have skipped the project verb entirely, silently ignoring + // update. The action must instead fail fast (issue #46 review). + fs.mkdirSync(tmpDir, { recursive: true }); + mockGetInput.mockImplementation((name: unknown) => { + switch (name) { + case 'working-directory': return tmpDir; + case 'dependencies': return 'microsoft/some-package'; + case 'update': return 'true'; + default: return ''; + } + }); + + await run(); + + expect(mockSetFailed).toHaveBeenCalledWith( + expect.stringContaining("'update: true' requires an apm.yml"), + ); + // The project verb must NOT have run (neither install nor update). + const apmCalls = mockExec.mock.calls.filter(c => c[0] === 'apm'); + expect(apmCalls.some(c => Array.isArray(c[1]) && c[1][0] === 'update')).toBe(false); + }); + + it('fails fast when update: true but no apm.yml is present (no deps)', async () => { + fs.mkdirSync(tmpDir, { recursive: true }); + mockGetInput.mockImplementation((name: unknown) => { + switch (name) { + case 'working-directory': return tmpDir; + case 'update': return 'true'; + default: return ''; + } + }); + + await run(); + + expect(mockSetFailed).toHaveBeenCalledWith( + expect.stringContaining("'update: true' requires an apm.yml"), + ); + const apmCalls = mockExec.mock.calls.filter(c => c[0] === 'apm'); + expect(apmCalls.some(c => Array.isArray(c[1]) && c[1][0] === 'update')).toBe(false); + }); }); describe('run (restore mode)', () => { diff --git a/src/runner.ts b/src/runner.ts index 169e62a..520f315 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -519,8 +519,22 @@ export async function run(): Promise { // inline deps. `update: true` swaps `apm install` for `apm update // --yes`, re-resolving branch/tag refs and rewriting the lockfile // non-interactively (microsoft/apm-action#46). - const projectVerb = update ? ['update', '--yes'] : ['install']; const apmYmlPath = path.join(resolvedDir, 'apm.yml'); + // `update` is a project-manifest refresh: it re-resolves the refs + // declared in apm.yml and rewrites apm.lock.yaml. With no apm.yml + // there is nothing to refresh, so fail fast rather than silently + // ignoring the flag -- the `dependencies` input is an additive + // inline install, not a manifest refresh. + if (update && !fs.existsSync(apmYmlPath)) { + throw new Error( + `'update: true' requires an apm.yml in the working directory to ` + + `refresh, but none was found at ${apmYmlPath}. 'apm update' ` + + `re-resolves apm.yml refs and rewrites apm.lock.yaml; the ` + + `'dependencies' input is an additive inline install, not a ` + + `manifest refresh. Commit an apm.yml, or remove update: true.`, + ); + } + const projectVerb = update ? ['update', '--yes'] : ['install']; if (fs.existsSync(apmYmlPath) || !depsInput) { await runApm(projectVerb, resolvedDir); }