feat(action): mode: release umbrella for tag-triggered publishes (#1348)#43
Merged
Merged
Conversation
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>
Contributor
There was a problem hiding this comment.
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.tsimplementing the release-mode pipeline and related helpers. - Adds early
modedispatch + mutual-exclusion checks insrc/runner.ts, plus new inputs/outputs inaction.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 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 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 on lines
+451
to
+452
| const marketplaceVersion = | ||
| tag.replace(/^v/i, '') || results.map(r => r.version).sort().pop() || 'unversioned'; |
|
|
||
| // 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`); |
| 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. |
|
|
||
| - **`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`. |
6 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
New
mode: releaseinput onmicrosoft/apm-actioncollapses 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.
Internally that runs:
apm pack --check-versions --check-clean --json-- the version-alignment + drift gate (apm 0.14.0+).apm pack --offline --archive.<tarball>.sha256sidecars.marketplace-<version>.jsonfor aggregators.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]Files
src/release.ts(NEW, ~17 KB) -- module surface above.src/runner.ts-- earlymodedispatch 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-- newRelease mode (one-step tag publish)section.Trade-offs
apm-versiondefault stays at0.13.0. Producers usingmode: releasemust explicitly pinapm-version: '0.14.0'until the default bump ships. Documented in the new README section.runReleaseModeis 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.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).apm0.14.0-dev from feat(pack): add --check-versions and --check-clean release gates apm#1365):How to test
Dependencies
producer/releasing-from-any-ci.md,producer/repo-shapes.md) and Wave 7 e2e producer-journey verification in microsoft/apm.