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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
52 changes: 34 additions & 18 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -41949,14 +41949,24 @@ async function runGate(workingDir) {
* Pack a single package: `cd <dir> && apm pack --offline --archive -o <dist>`.
* 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',
Expand All @@ -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))
Expand Down
15 changes: 11 additions & 4 deletions dist/release.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,17 @@ export declare function runGate(workingDir: string): Promise<{
* Pack a single package: `cd <dir> && apm pack --offline --archive -o <dist>`.
* 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<string>;
/**
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
24 changes: 24 additions & 0 deletions src/__tests__/release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Comment on lines +341 to +346
});

describe('runReleaseMode (integration, mocked exec)', () => {
Expand Down
47 changes: 31 additions & 16 deletions src/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,17 +284,27 @@ export async function runGate(
* Pack a single package: `cd <dir> && apm pack --offline --archive -o <dist>`.
* 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<string> {
fs.mkdirSync(distDir, { recursive: true });
const packStartMs = Date.now();
const before = new Map<string, number>();
for (const p of listTarballs(distDir)) {
before.set(p, fs.statSync(p).mtimeMs);
}
const rc = await exec.exec('apm', [
'pack',
'--offline',
Expand All @@ -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];
Comment on lines 341 to +346
}

function listTarballs(dir: string): string[] {
Expand Down
Loading