Skip to content

feat(action): mode: release umbrella for tag-triggered publishes (#1348)#43

Merged
danielmeppiel merged 2 commits into
mainfrom
danielmeppiel/wave-5-mode-release
May 18, 2026
Merged

feat(action): mode: release umbrella for tag-triggered publishes (#1348)#43
danielmeppiel merged 2 commits into
mainfrom
danielmeppiel/wave-5-mode-release

Conversation

@danielmeppiel
Copy link
Copy Markdown
Collaborator

TL;DR

New mode: release input on microsoft/apm-action collapses the canonical release pipeline -- gate, matrix-pack with sha256 sidecars, marketplace.json drift detection, GH Step Summary, gh release create -- into a single step. Vendor-neutral by construction: the wrapper is built from CLI primitives that run identically on GitLab CI, Jenkins, and Azure DevOps.

Closes Wave 5 of microsoft/apm#1348.

Problem (WHY)

Producers shipping plugin marketplaces from a tag push currently write 60-100 lines of GH Actions YAML to do what is conceptually one operation: "release this tag." Every Zava-style monorepo reinvents the same gate / pack / sidecar / drift / publish sequence. That hand-rolled pipeline is the surface where vendor-lock-in creeps in -- because the action is the only neat wrapper today, producers reach for action-specific features and the underlying CLI primitives stay invisible.

Discussion thread context: microsoft/apm#1322 + microsoft/apm#1332.

Approach (WHAT)

One umbrella input, four outputs, vendor-neutral primitives underneath.

- uses: microsoft/apm-action@v1
  with:
    mode: release
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Internally that runs:

  1. apm pack --check-versions --check-clean --json -- the version-alignment + drift gate (apm 0.14.0+).
  2. Detect repo shape (aggregator vs single-plugin) from filesystem layout.
  3. Matrix-pack every package with apm pack --offline --archive.
  4. Write <tarball>.sha256 sidecars.
  5. Stage marketplace-<version>.json for aggregators.
  6. Render GH Step Summary table.
  7. gh release create (skippable for dry runs).

Every step is a documented CLI primitive that the producer can call directly. The action is a convenience wrapper, NOT the canonical path. Wave 6 docs (producer/releasing-from-any-ci.md) make the GitLab / Jenkins / ADO equivalents explicit.

Implementation (HOW)

flowchart LR
    A[Tag push v*] --> B[runner.ts mode dispatch]
    B --> C[release.ts: runReleaseMode]
    C --> D[resolveReleaseTag]
    C --> E[detectShape]
    E --> F[discoverPackages]
    F --> G[runGate apm pack --check-versions --check-clean]
    G -->|exit 3 misalign| X[fail]
    G -->|exit 4 drift| Y[set marketplace-drift=true, fail]
    G -->|exit 0| H[for each package: packPackage]
    H --> I[writeSha256Sidecar]
    I --> J[stageMarketplaceJson]
    J --> K[Step Summary]
    K -->|skip-publish false| L[gh release create]
    K -->|skip-publish true| M[exit 0]
Loading

Files

  • src/release.ts (NEW, ~17 KB) -- module surface above.
  • src/runner.ts -- early mode dispatch before classic input handling; mutual-exclusion check.
  • src/__tests__/release.test.ts (NEW) -- 26 tests covering every export, every gate exit code.
  • src/__tests__/runner.test.ts -- 8 new mode-dispatch tests.
  • action.yml -- 7 new inputs, 4 new outputs.
  • dist/index.js -- regenerated.
  • tests/fixtures/release/{aggregator,single-plugin}/ -- minimal real-world fixtures.
  • tests/e2e/run-release-fixture.sh -- gated e2e harness (APM_ACTION_E2E=1).
  • CHANGELOG.md -- Unreleased entry.
  • README.md -- new Release mode (one-step tag publish) section.

Trade-offs

  1. Requires apm 0.14.0+ for the gate. action.yml apm-version default stays at 0.13.0. Producers using mode: release must explicitly pin apm-version: '0.14.0' until the default bump ships. Documented in the new README section.
  2. runReleaseMode is a wrapper, not a new abstraction. Each step has a named CLI equivalent. We intentionally did NOT add new pack/release sub-verbs; the primitives already exist and we just compose them.
  3. No CI matrix yet for e2e. The e2e harness is APM_ACTION_E2E=1-gated and runs locally against the venv-installed apm. Wiring it into CI requires either pinning to an apm release tarball or building apm from main; deferred to a follow-up issue.

Validation evidence

  • npm run typecheck -- clean.
  • npm run lint (eslint) -- clean.
  • npm test -- 175 pass / 175 (135 baseline + 40 new).
  • E2E harness with real apm CLI (apm 0.14.0-dev from feat(pack): add --check-versions and --check-clean release gates apm#1365):
    • aggregator-happy-path: 2 tarballs + 2 sha256 sidecars + marketplace-1.0.0.json -- PASS.
    • single-plugin-happy-path: 1 tarball + 1 sha256 sidecar -- PASS.

How to test

# Unit
npm install && npm run typecheck && npm run lint && npm test

# E2E (requires apm 0.14.0+ on PATH and node + bash)
APM_ACTION_E2E=1 bash tests/e2e/run-release-fixture.sh

Dependencies

Collapse the canonical release pipeline -- gate (apm pack --check-versions
--check-clean), per-package matrix pack with sha256 sidecars, marketplace.json
drift detection, GH Step Summary, gh release create -- into a single GH Actions
input.

The CLI primitives underneath stay vendor-neutral: the equivalent 10-line shell
recipe runs unchanged on GitLab CI, Jenkins, ADO. runReleaseMode is a
convenience wrapper around documented primitives, not the canonical path.

New inputs (all optional): mode (release), release-tag, release-name,
release-notes, release-draft, release-prerelease (auto-detects -rc/-alpha/-beta
/-pre suffix), release-skip-publish (dry-run for e2e).

New outputs: packages (JSON), marketplace-drift, release-url, release-tag.

Mutually exclusive with classic pack/bundle/bundles-file/setup-only inputs;
combinations fail fast with a consolidated error.

Tests:
- src/__tests__/release.test.ts: 26 new (resolveReleaseTag, detectShape,
  discoverPackages, resolvePrerelease, runGate exit codes, packPackage,
  writeSha256Sidecar, stageMarketplaceJson).
- src/__tests__/runner.test.ts: 8 new mode-dispatch tests.
- Full suite: 175 pass (135 baseline + 40 new).

E2E:
- tests/e2e/run-release-fixture.sh (gated by APM_ACTION_E2E=1).
- Fixtures: aggregator (alpha+beta plugins) and single-plugin (flat).
- Verified end-to-end: 2/2 tarballs + sidecars + marketplace.json on aggregator;
  1/1 tarball + sidecar on single-plugin.

Requires apm >= 0.14.0 on PATH (Wave 4 PR #1365 -- merged on apm main; awaits
release). action.yml default apm-version stays 0.13.0; bump in a follow-up once
0.14.0 ships.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 18, 2026 16:19
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new high-level mode: release input to microsoft/apm-action that runs an opinionated, tag-driven release pipeline (gate → pack → sha256 sidecars → optional marketplace staging → Step Summary → optional gh release create) as a single action step.

Changes:

  • Introduces src/release.ts implementing the release-mode pipeline and related helpers.
  • Adds early mode dispatch + mutual-exclusion checks in src/runner.ts, plus new inputs/outputs in action.yml.
  • Adds unit tests and an opt-in E2E harness with fixture repos; updates README/CHANGELOG and regenerates dist/.
Show a summary per file
File Description
tests/fixtures/release/single-plugin/apm.yml Adds single-plugin fixture metadata for release-mode E2E.
tests/fixtures/release/single-plugin/.apm/skills/example/SKILL.md Adds fixture content for packing validation.
tests/fixtures/release/aggregator/apm.yml Adds aggregator fixture with marketplace package list.
tests/fixtures/release/aggregator/plugins/alpha/apm.yml Adds plugin fixture metadata.
tests/fixtures/release/aggregator/plugins/alpha/.apm/skills/example/SKILL.md Adds plugin fixture content.
tests/fixtures/release/aggregator/plugins/beta/apm.yml Adds plugin fixture metadata.
tests/fixtures/release/aggregator/plugins/beta/.apm/skills/example/SKILL.md Adds plugin fixture content.
tests/e2e/run-release-fixture.sh Adds gated E2E harness running dist/index.js against fixtures.
src/runner.ts Adds mode orchestration dispatch and release wiring + outputs.
src/release.ts New release-mode implementation (gate, pack, sha256, marketplace staging, summary, publish).
src/tests/runner.test.ts Adds tests for mode: release dispatch and conflict validation.
src/tests/release.test.ts Adds unit coverage for release helpers and pipeline (mocked exec).
README.md Documents “Release mode (one-step tag publish)”.
action.yml Adds new inputs/outputs for release mode and documents behavior.
CHANGELOG.md Adds Unreleased entry for mode: release.
dist/release.d.ts Generated type declarations for the new module.
dist/index.js Regenerated bundled output including release mode.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 15/17 changed files
  • Comments generated: 6

Comment thread src/runner.ts Outdated
Comment on lines +131 to +132
if (!process.env.GITHUB_TOKEN) process.env.GITHUB_TOKEN = ghToken;
if (!process.env.GITHUB_APM_PAT) process.env.GITHUB_APM_PAT = ghToken;
Comment thread src/release.ts
Comment on lines +260 to +279
fs.mkdirSync(distDir, { recursive: true });
const before = listTarballs(distDir);
const rc = await exec.exec('apm', [
'pack',
'--offline',
'--archive',
'-o', distDir,
], { cwd: dir, ignoreReturnCode: true });
if (rc !== 0) {
throw new Error(`apm pack failed for ${dir} (exit ${rc})`);
}
const after = listTarballs(distDir);
const fresh = after.filter(p => !before.includes(p));
if (fresh.length === 0) {
throw new Error(
`apm pack in ${dir} succeeded but produced no .tar.gz in ${distDir}. `
+ `Verify that the package has a 'dependencies:' block or primitives `
+ `to bundle.`,
);
}
Comment thread src/release.ts Outdated
Comment on lines +451 to +452
const marketplaceVersion =
tag.replace(/^v/i, '') || results.map(r => r.version).sort().pop() || 'unversioned';
Comment thread src/release.ts Outdated

// Write notes to a tmp file so we don't fight `gh`'s argument escaping
// for multi-line content.
const notesFile = path.join(distDir, `.release-notes-${tag}.md`);
Comment thread action.yml Outdated
Set by mode: release. JSON array of packaged artifacts produced by
the release pipeline. Each element has:
{name, version, bundle, sha256, sha256_path}
Empty array when mode != release.
Comment thread CHANGELOG.md Outdated

- **`mode: release` umbrella for tag-triggered releases** ([microsoft/apm#1348]). A single input collapses the release pipeline into one step: gate (`apm pack --check-versions --check-clean --json`), per-package matrix pack with sha256 sidecars, marketplace.json drift detection, GH Step Summary, and `gh release create` publish. The CLI primitives underneath stay vendor-neutral — the equivalent shell recipe works unchanged on GitLab CI, Jenkins, and Azure DevOps (see `producer/releasing-from-any-ci.md`).
- New inputs (all optional): `mode` (`release`), `release-tag`, `release-name`, `release-notes`, `release-draft`, `release-prerelease` (`true`/`false`/`auto` — auto-detects from `-rc`/`-alpha`/`-beta`/`-pre` in the tag), `release-skip-publish` (dry-run for CI / e2e).
- New outputs: `packages` (JSON array of `{name, version, sha256, path}`), `marketplace-drift` (`true`/`false`), `release-url`, `release-tag`.
Six fixes from Copilot's review pass on PR #43:

1. src/runner.ts:128 -- token precedence bug. Mode dispatch was
   unconditionally setting GITHUB_APM_PAT whenever the github-token
   input was present, shadowing any caller-supplied GITHUB_TOKEN
   (APM's resolver prefers GITHUB_APM_PAT > GITHUB_TOKEN). Mirror
   the classic-path guard at line 217-223: capture pre-call state
   of GITHUB_TOKEN, only set GITHUB_APM_PAT when the caller did NOT
   provide GITHUB_TOKEN.

2. src/release.ts:packPackage -- tarball-overwrite false-negative.
   The before/after directory diff threw when apm pack overwrote
   an existing tarball of the same name (fresh.length === 0).
   Replace with mtime-based selection: capture pack start time,
   accept any tarball whose mtime is at-or-after start (1s grace
   for filesystem mtime granularity). Adds a regression test that
   stages a stale tarball with old mtime, runs pack to overwrite,
   asserts the function returns the path.

3. src/release.ts -- new exported sanitizeTagForPath() helper.
   Git tags can legally include /, .., control chars, and other
   path-delimiter bytes. Allow only [A-Za-z0-9._-], collapse
   everything else to dashes, drop dots adjacent to dashes,
   strip leading/trailing punctuation, collapse dash runs. Empty
   input -> 'unversioned'. Six unit tests cover safe-tag pass-through,
   path-separator replacement, leading-dot stripping, control-char
   handling, dot-run collapse, and pathological-input fallback.

4. src/release.ts:451 -- marketplace.json suffix path traversal.
   marketplaceVersion was derived from the raw tag and used in
   stageMarketplaceJson()'s output filename. Apply sanitizeTagForPath
   to the version suffix; original tag still passed verbatim to
   gh release create downstream.

5. src/release.ts:484 -- release-notes temp filename path traversal.
   '.release-notes-${tag}.md' was constructed from the raw tag and
   path.join'd with distDir; a tag of 'v1/../../etc/passwd' could
   escape distDir. Apply sanitizeTagForPath to the basename only;
   original tag still passed to gh release create.

6. action.yml + CHANGELOG.md -- packages output contract.
   - action.yml: clarify that packages is always set to '[]' outside
     mode: release (was 'unset rather than empty array').
   - src/runner.ts: emit core.setOutput('packages', '[]') at the top
     of run() so downstream fromJSON() steps work unconditionally.
   - CHANGELOG.md: fix field name mismatch -- documented shape was
     {name, version, sha256, path} but actual implementation emits
     {name, version, bundle, sha256, sha256_path}. Align changelog
     with reality and note the '[]' default.

Tests: 181 passed (was 175), build clean. dist/ regenerated.

Closes inline review comments 3260453546, 3260453610, 3260453631,
3260453660, 3260453692, 3260453725.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel danielmeppiel merged commit 1a82557 into main May 18, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants