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
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
- uses: actions/checkout@v4
- name: Validate extension release
id: extensionops
uses: reflectme-source/[email protected].1
uses: reflectme-source/[email protected].2
# For supply-chain pinning, replace the tag with the immutable release commit SHA.
with:
manifest-path: manifest.json # Optional; omit for auto-discovery.
Expand All @@ -46,13 +46,22 @@ jobs:
if-no-files-found: warn
```

**Version note:** Use the published `v1.0.1` tag, or pin an audited release commit SHA for stronger supply-chain guarantees. The action does not publish to any browser store.
**Version note:** Use the published `v1.0.2` tag, or pin an audited release commit SHA for stronger supply-chain guarantees. The action does not publish to any browser store.

## Frameworks with generated manifests (WXT and Plasmo)

Frameworks frequently generate the Chrome manifest during a production build. Validate **after** building so checks use the manifest you will actually ship, not a placeholder source file:

- **WXT:** [Copyable WXT workflow](examples/wxt-release-check.yml) uses `pnpm wxt build` and `manifest-path: .output/chrome-mv3/manifest.json`.
- **Plasmo:** [Copyable Plasmo workflow](examples/plasmo-release-check.yml) uses `pnpm build` and `manifest-path: build/chrome-mv3-prod/manifest.json`.

These examples use pnpm and assume your repository includes an appropriate lockfile and a compatible `packageManager` setting. Adapt install/build steps to your own project. The validation action does **not** need publishing secrets, submit releases, or inspect unpublished store accounts. It checks the selected generated manifest and reviewable repository source; it is **not** a complete binary audit of all bundled output.

## Inputs and outputs

| Name | Direction | Description |
| --- | --- | --- |
| `manifest-path` | Input | Optional path to `manifest.json`; leave blank for auto-discovery. |
| `manifest-path` | Input | Optional workspace-relative path to the actual `manifest.json`, including a generated build output. Leave blank for auto-discovery; a missing, invalid, or out-of-workspace explicit path fails validation. |
| `score` | Output | Release Confidence Score for checks executed by this action. |
| `report` | Output | Relative report path: `.extensionops/report.json`. |

Expand Down
31 changes: 31 additions & 0 deletions examples/plasmo-release-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Plasmo + pnpm reference workflow. Adjust to your repository's build script.
name: Plasmo extension release validation
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
validate-extension:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Build extension
run: |
corepack enable
pnpm install --frozen-lockfile
pnpm build
- name: Validate built Chrome MV3 manifest and source
uses: reflectme-source/[email protected]
with:
manifest-path: build/chrome-mv3-prod/manifest.json
- name: Save validation report
if: always()
uses: actions/upload-artifact@v4
with:
name: extensionops-plasmo-evidence
path: .extensionops/report.json
if-no-files-found: warn
31 changes: 31 additions & 0 deletions examples/wxt-release-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# WXT + pnpm reference workflow. Adjust to your repository's build script.
name: WXT extension release validation
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
validate-extension:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Build extension
run: |
corepack enable
pnpm install --frozen-lockfile
pnpm wxt build
- name: Validate built Chrome MV3 manifest and source
uses: reflectme-source/[email protected]
with:
manifest-path: .output/chrome-mv3/manifest.json
- name: Save validation report
if: always()
uses: actions/upload-artifact@v4
with:
name: extensionops-wxt-evidence
path: .extensionops/report.json
if-no-files-found: warn
57 changes: 53 additions & 4 deletions test/validator.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";

const validator = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../validate.mjs");

function validate(files) {
function validate(files, manifestPath = "") {
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "extensionops-action-"));
try {
for (const [relative, contents] of Object.entries(files)) {
Expand All @@ -18,12 +18,13 @@ function validate(files) {
}
const execution = spawnSync(process.execPath, [validator], {
cwd: workspace,
env: { ...process.env, GITHUB_WORKSPACE: workspace, GITHUB_OUTPUT: "" },
env: { ...process.env, GITHUB_WORKSPACE: workspace, GITHUB_OUTPUT: "", EXTENSIONOPS_MANIFEST_PATH: manifestPath },
encoding: "utf8",
});
assert.equal(execution.error, undefined, execution.stderr);
const report = JSON.parse(fs.readFileSync(path.join(workspace, ".extensionops/report.json"), "utf8"));
return { status: execution.status, report };
const reportPath = path.join(workspace, ".extensionops/report.json");
const report = fs.existsSync(reportPath) ? JSON.parse(fs.readFileSync(reportPath, "utf8")) : null;
return { status: execution.status, report, stderr: execution.stderr };
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
Expand Down Expand Up @@ -58,3 +59,51 @@ test("still checks API permissions when extension manifest exists", () => {
assert.equal(status, 0);
assert.ok(report.findings.some(f => f.ruleId === "MISSING_API_PERMISSION" && f.severity === "high"));
});

test("uses a generated Plasmo manifest inside the ignored build directory", () => {
const { status, report } = validate({
"build/chrome-mv3-prod/manifest.json": JSON.stringify({
manifest_version: 3, name: "Built extension", version: "1.0.0", permissions: ["history"],
}),
"src/content.js": "chrome.history.search({ text: '', maxResults: 5 });",
}, "build/chrome-mv3-prod/manifest.json");
assert.equal(status, 0);
assert.equal(report.manifestSeen, true);
assert.ok(!report.findings.some(f => f.ruleId === "MISSING_API_PERMISSION"));
});

test("explicit WXT output manifest takes precedence over a root manifest", () => {
const { status, report } = validate({
"manifest.json": JSON.stringify({ manifest_version: 3, name: "Old", version: "1.0.0", permissions: [] }),
".output/chrome-mv3/manifest.json": JSON.stringify({
manifest_version: 3, name: "Built", version: "1.0.0", permissions: ["history"],
}),
"src/content.js": "chrome.history.search({ text: '', maxResults: 5 });",
}, ".output/chrome-mv3/manifest.json");
assert.equal(status, 0);
assert.equal(report.manifestSeen, true);
assert.ok(!report.findings.some(f => f.ruleId === "MISSING_API_PERMISSION"));
});

test("fails closed when configured manifest is missing", () => {
const { status, report, stderr } = validate({}, "build/chrome-mv3-prod/manifest.json");
assert.notEqual(status, 0);
assert.equal(report, null);
assert.match(stderr, /ENOENT/);
});

test("rejects manifest paths that escape the workspace", () => {
const { status, report, stderr } = validate({}, "../manifest.json");
assert.notEqual(status, 0);
assert.equal(report, null);
assert.match(stderr, /must stay inside/);
});

test("rejects malformed configured manifest JSON", () => {
const { status, report, stderr } = validate({
".output/chrome-mv3/manifest.json": "{broken",
}, ".output/chrome-mv3/manifest.json");
assert.notEqual(status, 0);
assert.equal(report, null);
assert.match(stderr, /valid browser-extension manifest JSON/);
});
44 changes: 44 additions & 0 deletions validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,51 @@ function scanActiveManifests() {
scanPermissionConsistency(active);
}

function selectExplicitManifest() {
const requested = process.env.EXTENSIONOPS_MANIFEST_PATH?.trim();
if (!requested) return;

// This input is a repository-relative path, never an arbitrary filesystem path.
if (path.isAbsolute(requested) || path.win32.isAbsolute(requested)) {
throw new Error("manifest-path must be relative to the GitHub workspace.");
}
const absolute = path.resolve(root, requested);
const relative = path.relative(root, absolute);
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw new Error("manifest-path must stay inside the GitHub workspace.");
}
if (path.basename(absolute).toLowerCase() !== "manifest.json") {
throw new Error("manifest-path must point to a manifest.json file.");
}

// Resolve symlinks before reading, so a checked-in symlink cannot escape the workspace.
const rootReal = fs.realpathSync(root);
const fileReal = fs.realpathSync(absolute);
const realRelative = path.relative(rootReal, fileReal);
if (!realRelative || realRelative === ".." || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative)) {
throw new Error("manifest-path resolves outside the GitHub workspace.");
}
const stat = fs.statSync(fileReal);
if (!stat.isFile() || stat.size > 512_000) {
throw new Error("manifest-path must reference a regular manifest.json of at most 512 KB.");
}
const content = fs.readFileSync(fileReal, "utf8");
try {
const parsed = JSON.parse(content);
if (!parsed || typeof parsed !== "object" || ![2, 3].includes(parsed.manifest_version)) {
throw new Error("Unsupported manifest version.");
}
} catch {
throw new Error("manifest-path must reference valid browser-extension manifest JSON.");
}

// An explicit manifest is authoritative: do not silently use another target.
manifestFiles.length = 0;
manifestFiles.push({ file: relative.replaceAll("\\", "/"), content });
}

walk(root);
selectExplicitManifest();
scanDependencyFiles();
scanActiveManifests();

Expand Down
Loading