diff --git a/CHANGELOG.md b/CHANGELOG.md index 208944b..ba78656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ The floating `v1` tag tracks the latest `1.x` release. Consumers pinning ## [Unreleased] +## [1.9.1] - 2026-05-19 + +### Fixed + +- **Spurious "produced N tarballs; expected 1" warnings in monorepo releases** ([microsoft/apm#1348]). `packPackage` snapshotted the shared `distDir` only after `apm pack` returned and treated any tarball with `mtime >= packStart - 1s` as "fresh by this invocation." In a monorepo loop, sequential per-package packs complete in well under a second, so plugin N saw plugin 1..N-1's tarballs inside the grace window and warned on every pack after the first. Fix: snapshot tarballs and their mtimes before the pack call and accept only files that are new in `after` or whose mtime advanced — the warning now fires only on a genuine producer-side anomaly. Empirically reproduced on [DevExpGbb/zava-agent-config@v6.1.1](https://github.com/DevExpGbb/zava-agent-config/releases/tag/v6.1.1) ([run 26079513903](https://github.com/DevExpGbb/zava-agent-config/actions/runs/26079513903)), which packs 7 plugins from one `dist/` and emitted 6 spurious warnings per release. + ## [1.9.0] - 2026-05-18 ### Added @@ -228,7 +234,8 @@ Initial public release. - **Marketplace name set to "Setup APM"** ([#5]). - **Microsoft OSS compliance baseline.** SECURITY.md ([#2]), CODEOWNERS, license, contributing guide, code of conduct, and CI pipeline. -[Unreleased]: https://github.com/microsoft/apm-action/compare/v1.9.0...HEAD +[Unreleased]: https://github.com/microsoft/apm-action/compare/v1.9.1...HEAD +[1.9.1]: https://github.com/microsoft/apm-action/compare/v1.9.0...v1.9.1 [1.9.0]: https://github.com/microsoft/apm-action/compare/v1.8.0...v1.9.0 [1.8.0]: https://github.com/microsoft/apm-action/compare/v1.7.3...v1.8.0 [1.7.3]: https://github.com/microsoft/apm-action/compare/v1.7.2...v1.7.3 diff --git a/dist/index.js b/dist/index.js index a00c964..d4ab7e7 100644 --- a/dist/index.js +++ b/dist/index.js @@ -41949,14 +41949,24 @@ async function runGate(workingDir) { * Pack a single package: `cd && apm pack --offline --archive -o `. * Returns the absolute path to the produced .tar.gz. * - * Selects the produced tarball by mtime (newest after pack) rather than - * diffing the directory before/after. This is robust to the case where - * `apm pack` overwrites an existing tarball of the same name -- the diff - * approach would see fresh=[] and incorrectly throw despite pack succeeding. + * Identifies the produced tarball by snapshotting `distDir` before and after + * the pack invocation. A tarball is "produced by this call" if it is new in + * `after`, or if it existed in `before` but its mtime advanced. This is + * correct under two conditions a naive mtime heuristic gets wrong: + * + * 1. Monorepo runs share `distDir`. Sequential per-package pack invocations + * complete in <1s each, so prior tarballs from the same run fall inside + * any reasonable "newer than packStart" grace window. The before/after + * diff isolates exactly the tarball this invocation touched. + * 2. Re-runs overwrite an existing tarball of the same name. A pure + * set-difference would miss this; mtime advance catches it. */ async function packPackage(dir, distDir) { external_node_fs_namespaceObject.mkdirSync(distDir, { recursive: true }); - const packStartMs = Date.now(); + const before = new Map(); + for (const p of listTarballs(distDir)) { + before.set(p, external_node_fs_namespaceObject.statSync(p).mtimeMs); + } const rc = await lib_exec/* exec */.m('apm', [ 'pack', '--offline', @@ -41972,19 +41982,25 @@ async function packPackage(dir, distDir) { + `Verify that the package has a 'dependencies:' block or primitives ` + `to bundle.`); } - // listTarballs sorts newest-first by mtime. Accept the newest tarball - // whose mtime is >= packStart (1s grace for fs mtime granularity). - const graceMs = packStartMs - 1000; - const fresh = after.filter(p => external_node_fs_namespaceObject.statSync(p).mtimeMs >= graceMs); - if (fresh.length === 0) { - throw new Error(`apm pack in ${dir} succeeded but no tarball in ${distDir} has an ` - + `mtime newer than the pack invocation. Filesystem clock skew?`); - } - if (fresh.length > 1) { - lib_core/* warning */.$e(`apm pack in ${dir} produced ${fresh.length} tarballs; expected 1. ` - + `Using the most recently modified: ${fresh[0]}`); - } - return fresh[0]; + const touched = after.filter(p => { + const prev = before.get(p); + if (prev === undefined) + return true; + return external_node_fs_namespaceObject.statSync(p).mtimeMs > prev; + }); + if (touched.length === 0) { + throw new Error(`apm pack in ${dir} succeeded but no tarball in ${distDir} was added ` + + `or modified by this invocation. Filesystem clock skew?`); + } + if (touched.length > 1) { + // One pack invocation is expected to produce or modify exactly one + // tarball. More than one is a real producer-side anomaly worth surfacing + // -- the before/after diff has already filtered out prior packages in + // the same monorepo run. + lib_core/* warning */.$e(`apm pack in ${dir} produced ${touched.length} tarballs; expected 1. ` + + `Using the most recently modified: ${touched.sort((a, b) => external_node_fs_namespaceObject.statSync(b).mtimeMs - external_node_fs_namespaceObject.statSync(a).mtimeMs)[0]}`); + } + return touched.sort((a, b) => external_node_fs_namespaceObject.statSync(b).mtimeMs - external_node_fs_namespaceObject.statSync(a).mtimeMs)[0]; } function listTarballs(dir) { if (!external_node_fs_namespaceObject.existsSync(dir)) diff --git a/dist/release.d.ts b/dist/release.d.ts index 5d61a03..c7d034b 100644 --- a/dist/release.d.ts +++ b/dist/release.d.ts @@ -100,10 +100,17 @@ export declare function runGate(workingDir: string): Promise<{ * Pack a single package: `cd && apm pack --offline --archive -o `. * Returns the absolute path to the produced .tar.gz. * - * Selects the produced tarball by mtime (newest after pack) rather than - * diffing the directory before/after. This is robust to the case where - * `apm pack` overwrites an existing tarball of the same name -- the diff - * approach would see fresh=[] and incorrectly throw despite pack succeeding. + * Identifies the produced tarball by snapshotting `distDir` before and after + * the pack invocation. A tarball is "produced by this call" if it is new in + * `after`, or if it existed in `before` but its mtime advanced. This is + * correct under two conditions a naive mtime heuristic gets wrong: + * + * 1. Monorepo runs share `distDir`. Sequential per-package pack invocations + * complete in <1s each, so prior tarballs from the same run fall inside + * any reasonable "newer than packStart" grace window. The before/after + * diff isolates exactly the tarball this invocation touched. + * 2. Re-runs overwrite an existing tarball of the same name. A pure + * set-difference would miss this; mtime advance catches it. */ export declare function packPackage(dir: string, distDir: string): Promise; /** diff --git a/package-lock.json b/package-lock.json index 1573a11..3e12f3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "apm-action", - "version": "1.0.0", + "version": "1.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "apm-action", - "version": "1.0.0", + "version": "1.9.1", "license": "MIT", "dependencies": { "@actions/core": "^3.0.0", diff --git a/package.json b/package.json index 65e94a9..580bf7f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apm-action", - "version": "1.0.0", + "version": "1.9.1", "description": "GitHub Action for APM - Agent Package Manager", "type": "module", "main": "dist/index.js", diff --git a/src/__tests__/release.test.ts b/src/__tests__/release.test.ts index 75e8d78..d36f375 100644 --- a/src/__tests__/release.test.ts +++ b/src/__tests__/release.test.ts @@ -320,6 +320,30 @@ describe('packPackage', () => { expect(result).toBe(tarballPath); expect(fs.readFileSync(tarballPath, 'utf8')).toBe('fresh'); }); + + it('returns only the tarball produced by this invocation in a shared distDir (monorepo regression)', async () => { + // Simulate a monorepo loop: distDir already contains prior packages' + // tarballs from THIS run (mtime just a few ms ago). A mtime-grace-window + // heuristic would classify them all as "fresh" and emit a spurious + // "produced N tarballs; expected 1" warning. The before/after diff + // must isolate only the file this invocation actually touched. + fs.mkdirSync(distDir, { recursive: true }); + const priorA = path.join(distDir, 'sibling-a-1.0.0.tar.gz'); + const priorB = path.join(distDir, 'sibling-b-1.0.0.tar.gz'); + fs.writeFileSync(priorA, 'a'); + fs.writeFileSync(priorB, 'b'); + + const newTarball = path.join(distDir, 'mypkg-1.0.0.tar.gz'); + mockExec.mockImplementationOnce(async () => { + fs.writeFileSync(newTarball, 'new'); + return 0; + }); + const result = await packPackage(tmpDir, distDir); + expect(result).toBe(newTarball); + // Sibling files must still exist on disk (we did not delete them). + expect(fs.existsSync(priorA)).toBe(true); + expect(fs.existsSync(priorB)).toBe(true); + }); }); describe('runReleaseMode (integration, mocked exec)', () => { diff --git a/src/release.ts b/src/release.ts index 83fd20a..045b911 100644 --- a/src/release.ts +++ b/src/release.ts @@ -284,17 +284,27 @@ export async function runGate( * Pack a single package: `cd && apm pack --offline --archive -o `. * Returns the absolute path to the produced .tar.gz. * - * Selects the produced tarball by mtime (newest after pack) rather than - * diffing the directory before/after. This is robust to the case where - * `apm pack` overwrites an existing tarball of the same name -- the diff - * approach would see fresh=[] and incorrectly throw despite pack succeeding. + * Identifies the produced tarball by snapshotting `distDir` before and after + * the pack invocation. A tarball is "produced by this call" if it is new in + * `after`, or if it existed in `before` but its mtime advanced. This is + * correct under two conditions a naive mtime heuristic gets wrong: + * + * 1. Monorepo runs share `distDir`. Sequential per-package pack invocations + * complete in <1s each, so prior tarballs from the same run fall inside + * any reasonable "newer than packStart" grace window. The before/after + * diff isolates exactly the tarball this invocation touched. + * 2. Re-runs overwrite an existing tarball of the same name. A pure + * set-difference would miss this; mtime advance catches it. */ export async function packPackage( dir: string, distDir: string, ): Promise { fs.mkdirSync(distDir, { recursive: true }); - const packStartMs = Date.now(); + const before = new Map(); + for (const p of listTarballs(distDir)) { + before.set(p, fs.statSync(p).mtimeMs); + } const rc = await exec.exec('apm', [ 'pack', '--offline', @@ -312,23 +322,28 @@ export async function packPackage( + `to bundle.`, ); } - // listTarballs sorts newest-first by mtime. Accept the newest tarball - // whose mtime is >= packStart (1s grace for fs mtime granularity). - const graceMs = packStartMs - 1000; - const fresh = after.filter(p => fs.statSync(p).mtimeMs >= graceMs); - if (fresh.length === 0) { + const touched = after.filter(p => { + const prev = before.get(p); + if (prev === undefined) return true; + return fs.statSync(p).mtimeMs > prev; + }); + if (touched.length === 0) { throw new Error( - `apm pack in ${dir} succeeded but no tarball in ${distDir} has an ` - + `mtime newer than the pack invocation. Filesystem clock skew?`, + `apm pack in ${dir} succeeded but no tarball in ${distDir} was added ` + + `or modified by this invocation. Filesystem clock skew?`, ); } - if (fresh.length > 1) { + if (touched.length > 1) { + // One pack invocation is expected to produce or modify exactly one + // tarball. More than one is a real producer-side anomaly worth surfacing + // -- the before/after diff has already filtered out prior packages in + // the same monorepo run. core.warning( - `apm pack in ${dir} produced ${fresh.length} tarballs; expected 1. ` - + `Using the most recently modified: ${fresh[0]}`, + `apm pack in ${dir} produced ${touched.length} tarballs; expected 1. ` + + `Using the most recently modified: ${touched.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0]}`, ); } - return fresh[0]; + return touched.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0]; } function listTarballs(dir: string): string[] {