diff --git a/.gitignore b/.gitignore
index 80a5f9b934ee9c38c5d1c8498770bbc95af51660..04bd12c80af4826aa3e489a99d0ad381aa2ccf81 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ dist
 dist-ssr
 *.local
 update.json
+scripts/_env.sh
diff --git a/package.json b/package.json
index 050bc5b9d3ebf7835382107fb5748fa64aebb14d..5f38d93ef157b4fba55b01ab4b103f1e2262f676 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
     "web:dev": "vite",
     "web:build": "tsc && vite build",
     "web:serve": "vite preview",
+    "aarch": "node scripts/aarch.mjs",
     "check": "node scripts/check.mjs",
     "updater": "node scripts/updater.mjs",
     "publish": "node scripts/publish.mjs",
diff --git a/scripts/aarch.mjs b/scripts/aarch.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..2969534791ad3e5ddf23ad0ec81d616510060289
--- /dev/null
+++ b/scripts/aarch.mjs
@@ -0,0 +1,104 @@
+/**
+ * Build and upload assets for macOS(aarch)
+ */
+import fs from "fs-extra";
+import path from "path";
+import { exit } from "process";
+import { createRequire } from "module";
+import { getOctokit, context } from "@actions/github";
+
+const require = createRequire(import.meta.url);
+
+async function resolve() {
+  if (!process.env.GITHUB_TOKEN) {
+    throw new Error("GITHUB_TOKEN is required");
+  }
+  if (!process.env.GITHUB_REPOSITORY) {
+    throw new Error("GITHUB_REPOSITORY is required");
+  }
+  if (!process.env.TAURI_PRIVATE_KEY) {
+    throw new Error("TAURI_PRIVATE_KEY is required");
+  }
+  if (!process.env.TAURI_KEY_PASSWORD) {
+    throw new Error("TAURI_KEY_PASSWORD is required");
+  }
+
+  const { version } = require("../package.json");
+
+  const cwd = process.cwd();
+  const bundlePath = path.join(cwd, "src-tauri/target/release/bundle");
+  const join = (p) => path.join(bundlePath, p);
+
+  const appPathList = [
+    join("macos/Clash Verge.aarch64.app.tar.gz"),
+    join("macos/Clash Verge.aarch64.app.tar.gz.sig"),
+  ];
+
+  for (const appPath of appPathList) {
+    if (fs.pathExistsSync(appPath)) {
+      fs.removeSync(appPath);
+    }
+  }
+
+  fs.copyFileSync(join("macos/Clash Verge.app.tar.gz"), appPathList[0]);
+  fs.copyFileSync(join("macos/Clash Verge.app.tar.gz.sig"), appPathList[1]);
+
+  const options = { owner: context.repo.owner, repo: context.repo.repo };
+  const github = getOctokit(process.env.GITHUB_TOKEN);
+
+  const { data: release } = await github.rest.repos.getReleaseByTag({
+    ...options,
+    tag: `v${version}`,
+  });
+
+  if (!release.id) throw new Error("failed to find the release");
+
+  await uploadAssets(release.id, [
+    join(`dmg/Clash Verge_${version}_aarch64.dmg`),
+    ...appPathList,
+  ]);
+}
+
+// From tauri-apps/tauri-action
+// https://github.com/tauri-apps/tauri-action/blob/dev/packages/action/src/upload-release-assets.ts
+async function uploadAssets(releaseId, assets) {
+  const github = getOctokit(process.env.GITHUB_TOKEN);
+
+  // Determine content-length for header to upload asset
+  const contentLength = (filePath) => fs.statSync(filePath).size;
+
+  for (const assetPath of assets) {
+    const headers = {
+      "content-type": "application/zip",
+      "content-length": contentLength(assetPath),
+    };
+
+    const ext = path.extname(assetPath);
+    const filename = path.basename(assetPath).replace(ext, "");
+    const assetName = path.dirname(assetPath).includes(`target${path.sep}debug`)
+      ? `${filename}-debug${ext}`
+      : `${filename}${ext}`;
+
+    console.log(`[INFO]: Uploading ${assetName}...`);
+
+    try {
+      await github.rest.repos.uploadReleaseAsset({
+        headers,
+        name: assetName,
+        data: fs.readFileSync(assetPath),
+        owner: context.repo.owner,
+        repo: context.repo.repo,
+        release_id: releaseId,
+      });
+    } catch (error) {
+      console.log(error.message);
+    }
+  }
+}
+
+if (process.platform === "darwin" && process.arch === "arm64") {
+  resolve();
+} else {
+  console.error("invalid");
+  exit(1);
+}