├── .babelrc ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── README.md ├── package.json └── src ├── index.js └── turbo └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | max_line_length = 120 3 | indent_style = tab 4 | quote_type = single -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@babel/eslint-parser', 3 | parserOptions: { 4 | requireConfigFile: false, 5 | babelOptions: { 6 | presets: ['@babel/preset-env'], 7 | }, 8 | }, 9 | plugins: ['import', 'simple-import-sort'], 10 | extends: [], 11 | rules: { 12 | 'import/no-duplicates': 'error', 13 | 'simple-import-sort/imports': 'error', 14 | 'simple-import-sort/exports': 'error', 15 | }, 16 | overrides: [ 17 | { 18 | files: ['*.js'], 19 | rules: { 20 | 'simple-import-sort/imports': [ 21 | 'error', 22 | { 23 | groups: [ 24 | ['^react', '^@?\\w'], 25 | ['^arweave', '@irys/sdk', '@permaweb/aoconnect', '^@?\\w'], 26 | ['^\\u0000'], 27 | ['^\\.\\.(?!/?$)', '^\\.\\./?$'], 28 | ['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'], 29 | ], 30 | }, 31 | ], 32 | }, 33 | }, 34 | ], 35 | }; 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **node_modules** 2 | **dist** 3 | **package-lock.json** 4 | **yarn.lock** 5 | **dist** 6 | **cache** 7 | **logs** 8 | **.DS_Store** 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Permaweb deployment package 2 | 3 | Inspired by the [cookbook github action deployment guide](https://cookbook.arweave.dev/guides/deployment/github-action.html), `permaweb-deploy` is a Node.js command-line tool designed to streamline the deployment of JavaScript bundles to the permaweb using Arweave. It simplifies the process by bundling JS code, deploying it as a transaction to Arweave, and updating ArNS (Arweave Name Service) with the transaction ID. 4 | 5 | ### Features 6 | 7 | - **Bundle Deployment:** Automatically bundles your JS code and deploys it to Arweave. 8 | - **ArNS Update:** Updates ArNS with the new transaction ID each time new content is deployed. 9 | - **Automated Workflow:** Integrates with GitHub Actions for continuous deployment directly from your repository. 10 | 11 | ### Installation 12 | 13 | Install the package using npm: 14 | 15 | ```bash 16 | npm install permaweb-deploy 17 | ``` 18 | 19 | ### Prerequisites 20 | 21 | Before using `permaweb-deploy`, you must: 22 | 23 | 1. Encode your Arweave wallet key in base64 format and set it as a GitHub secret: 24 | 25 | ```bash 26 | base64 -i wallet.json | pbcopy 27 | ``` 28 | 29 | 2. Ensure that the secret name for the encoded wallet is `DEPLOY_KEY`. 30 | 31 | ### Usage 32 | 33 | To deploy your application, ensure you have a build script and a deployment script in your `package.json`: 34 | 35 | ```json 36 | "scripts": { 37 | "build": "your-build-command", 38 | "deploy-main": "npm run build && permaweb-deploy --arns-name " 39 | } 40 | ``` 41 | 42 | Replace `` with your ArNS name. There is the additional, optional flag `--undername`. If you want to deploy your app to an undername on an ArNS name, provide that name with this flag `--arns-name --undername ` 43 | 44 | You can also specify testnet, mainnet, and custom process id's for the ARIO process to use. 45 | 46 | Maninnet (default) config: 47 | 48 | ```json 49 | "scripts": { 50 | "build": "your-build-command", 51 | "deploy-main": "npm run build && permaweb-deploy --arns-name --ario-process mainnet" 52 | } 53 | ``` 54 | 55 | Testnet config: 56 | 57 | ```json 58 | "scripts": { 59 | "build": "your-build-command", 60 | "deploy-main": "npm run build && permaweb-deploy --arns-name --ario-process testnet" 61 | } 62 | ``` 63 | 64 | Custom process ID config: 65 | 66 | ```json 67 | "scripts": { 68 | "build": "your-build-command", 69 | "deploy-main": "npm run build && permaweb-deploy --arns-name --ario-process GaQrvEMKBpkjofgnBi_B3IgIDmY_XYelVLB6GcRGrHc" 70 | } 71 | ``` 72 | 73 | ### GitHub Actions Workflow 74 | 75 | To automate the deployment, set up a GitHub Actions workflow as follows: 76 | 77 | ```yaml 78 | name: publish 79 | 80 | on: 81 | push: 82 | branches: 83 | - 'main' 84 | 85 | jobs: 86 | publish: 87 | runs-on: ubuntu-latest 88 | steps: 89 | - uses: actions/checkout@v2 90 | - uses: actions/setup-node@v1 91 | with: 92 | node-version: 20.x 93 | - run: npm install 94 | - run: npm run deploy-main 95 | env: 96 | DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} 97 | ``` 98 | 99 | ### To deploy to permaweb manually via cli 100 | 101 | ```sh 102 | DEPLOY_KEY=$(base64 -i wallet.json) npx permaweb-deploy --arns-name 103 | ``` 104 | 105 | ### Important Notes 106 | 107 | - **Security:** Always use a dedicated wallet for deployments to minimize risk. 108 | - **Wallet Key:** The wallet must be base64 encoded to be used in the deployment script. 109 | - **ARNS Name:** The ArNS Name must be passed in so that the ANT Process can be resolved to update the target undername or root record. 110 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "permaweb-deploy", 3 | "version": "2.1.0", 4 | "description": "Permaweb app deployment package", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "babel src --out-dir dist" 8 | }, 9 | "bin": { 10 | "permaweb-deploy": "./dist/index.js" 11 | }, 12 | "dependencies": { 13 | "@ar.io/sdk": "^3.10.1", 14 | "@ardrive/turbo-sdk": "^1.17.0", 15 | "@permaweb/aoconnect": "^0.0.84", 16 | "mime-types": "^2.1.35", 17 | "yargs": "17.7.2" 18 | }, 19 | "devDependencies": { 20 | "@babel/cli": "^7.23.9", 21 | "@babel/core": "^7.23.9", 22 | "@babel/eslint-parser": "^7.23.10", 23 | "@babel/preset-env": "^7.23.9", 24 | "eslint": "^8.35.0", 25 | "eslint-plugin-import": "^2.27.5", 26 | "eslint-plugin-simple-import-sort": "^10.0.0" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/permaweb/permaweb-deploy.git" 31 | }, 32 | "author": "NickJ202", 33 | "license": "ISC", 34 | "bugs": { 35 | "url": "https://github.com/permaweb/permaweb-deploy/issues" 36 | }, 37 | "homepage": "https://github.com/permaweb/permaweb-deploy#readme" 38 | } 39 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { ANT, AOProcess, ARIO, ARIO_MAINNET_PROCESS_ID, ARIO_TESTNET_PROCESS_ID, ArweaveSigner } from '@ar.io/sdk'; 4 | import fs from 'fs'; 5 | import yargs from 'yargs'; 6 | import { hideBin } from 'yargs/helpers'; 7 | 8 | import { connect } from '@permaweb/aoconnect'; 9 | 10 | import TurboDeploy from './turbo'; 11 | 12 | const arweaveTxIdRegex = /^[a-zA-Z0-9-_]{43}$/; 13 | 14 | const argv = yargs(hideBin(process.argv)) 15 | .option('ario-process', { 16 | alias: 'p', 17 | type: 'string', 18 | description: 'The ARIO process to use', 19 | demandOption: true, 20 | default: ARIO_MAINNET_PROCESS_ID 21 | }) 22 | .option('arns-name', { 23 | alias: 'n', 24 | type: 'string', 25 | description: 'The ARNS name', 26 | demandOption: true, 27 | }) 28 | .option('deploy-folder', { 29 | alias: 'd', 30 | type: 'string', 31 | description: 'Folder to deploy.', 32 | default: './dist', 33 | }) 34 | .option('undername', { 35 | alias: 'u', 36 | type: 'string', 37 | description: 'ANT undername to update.', 38 | default: '@', 39 | }).argv; 40 | 41 | const DEPLOY_KEY = process.env.DEPLOY_KEY; 42 | const ARNS_NAME = argv.arnsName; 43 | let ARIO_PROCESS = argv.arioProcess; 44 | if (ARIO_PROCESS === 'mainnet') { 45 | ARIO_PROCESS = ARIO_MAINNET_PROCESS_ID; 46 | } else if (ARIO_PROCESS === 'testnet') { 47 | ARIO_PROCESS = ARIO_TESTNET_PROCESS_ID; 48 | } 49 | 50 | export function getTagValue(list, name) { 51 | for (let i = 0; i < list.length; i++) { 52 | if (list[i]) { 53 | if (list[i].name === name) { 54 | return list[i].value; 55 | } 56 | } 57 | } 58 | return STORAGE.none; 59 | } 60 | 61 | (async () => { 62 | if (!ARIO_PROCESS || !arweaveTxIdRegex.test(ARIO_PROCESS)) { 63 | console.error('ARIO_PROCESS must be a valid Arweave transaction ID, or "mainnet" or "testnet"'); 64 | process.exit(1); 65 | } 66 | 67 | if (!DEPLOY_KEY) { 68 | console.error('DEPLOY_KEY not configured'); 69 | process.exit(1); 70 | } 71 | 72 | if (!ARNS_NAME) { 73 | console.error('ARNS_NAME not configured'); 74 | process.exit(1); 75 | } 76 | 77 | 78 | if (argv.deployFolder.length == 0) { 79 | console.error('deploy folder must not be empty'); 80 | process.exit(1); 81 | } 82 | 83 | if (argv.undername.length == 0) { 84 | console.error('undername must not be empty'); 85 | process.exit(1); 86 | } 87 | 88 | if (!fs.existsSync(argv.deployFolder)) { 89 | console.error(`deploy folder [${argv.deployFolder}] does not exist`); 90 | process.exit(1); 91 | } 92 | 93 | const jwk = JSON.parse(Buffer.from(DEPLOY_KEY, 'base64').toString('utf-8')); 94 | const ario = ARIO.init({process: new AOProcess({ 95 | processId: ARIO_PROCESS, 96 | ao: connect({ 97 | MODE: 'legacy', 98 | CU_URL:"https://cu.ardrive.io" 99 | }) 100 | })}); 101 | const arnsNameRecord = await ario.getArNSRecord({name: ARNS_NAME}).catch((e) => { 102 | console.error(`ARNS name [${ARNS_NAME}] does not exist`); 103 | process.exit(1); 104 | }); 105 | 106 | 107 | try { 108 | const manifestId = await TurboDeploy(argv, jwk); 109 | const signer = new ArweaveSigner(jwk); 110 | const ant = ANT.init({ processId: arnsNameRecord.processId, signer }); 111 | 112 | // Update the ANT record (assumes the JWK is a controller or owner) 113 | await ant.setRecord( 114 | { 115 | undername: argv.undername, 116 | transactionId: manifestId, 117 | ttlSeconds: 3600, 118 | },{ 119 | tags: [ 120 | { 121 | name: 'App-Name', 122 | value: 'Permaweb-Deploy', 123 | }, 124 | ...(process.env.GITHUB_SHA ? [{ 125 | name: 'GIT-HASH', 126 | value: process.env.GITHUB_SHA, 127 | }] : []), 128 | ] 129 | } 130 | ); 131 | 132 | console.log(`Deployed TxId [${manifestId}] to name [${ARNS_NAME}] for ANT [${arnsNameRecord.processId}] using undername [${argv.undername}]`); 133 | } catch (e) { 134 | console.error('Deployment failed:', e); 135 | process.exit(1); // Exit with error code 136 | } 137 | })(); 138 | -------------------------------------------------------------------------------- /src/turbo/index.js: -------------------------------------------------------------------------------- 1 | import { TurboFactory } from '@ardrive/turbo-sdk'; 2 | import fs from 'fs'; 3 | import mime from 'mime-types'; 4 | import path from 'path'; 5 | import { Readable } from 'stream'; 6 | 7 | // Gets MIME types for each file to tag the upload 8 | function getContentType(filePath) { 9 | const res = mime.lookup(filePath); 10 | return res || 'application/octet-stream'; 11 | } 12 | 13 | export default async function TurboDeploy(argv, jwk) { 14 | const turbo = TurboFactory.authenticated({ privateKey: jwk }); 15 | 16 | const deployFolder = argv.deployFolder; 17 | //Uses Arweave manifest version 0.2.0, which supports fallbacks 18 | let newManifest = { 19 | manifest: 'arweave/paths', 20 | version: '0.2.0', 21 | index: { path: 'index.html' }, 22 | fallback: {}, 23 | paths: {}, 24 | }; 25 | 26 | async function processFiles(dir) { 27 | const files = fs.readdirSync(dir); 28 | 29 | for (const file of files) { 30 | try { 31 | const filePath = path.join(dir, file); 32 | const relativePath = path.relative(deployFolder, filePath); 33 | 34 | if (fs.statSync(filePath).isDirectory()) { 35 | // recursively process all files in a directory 36 | await processFiles(filePath); 37 | } else { 38 | console.log(`Uploading file: ${relativePath}`); 39 | try { 40 | const fileSize = fs.statSync(filePath).size; 41 | const contentType = getContentType(filePath); 42 | const uploadResult = await turbo.uploadFile({ 43 | fileStreamFactory: () => fs.createReadStream(filePath), 44 | fileSizeFactory: () => fileSize, 45 | signal: AbortSignal.timeout(10_000), // cancel the upload after 10 seconds 46 | dataItemOpts: { 47 | tags: [ 48 | { name: 'Content-Type', value: contentType }, 49 | { name: 'App-Name', value: 'Permaweb-Deploy' }, 50 | ], 51 | }, 52 | }); 53 | 54 | console.log(`Uploaded ${relativePath} with id:`, uploadResult.id); 55 | // adds uploaded file txId to the new manifest json 56 | newManifest.paths[relativePath] = { id: uploadResult.id }; 57 | 58 | if (file === '404.html') { 59 | // sets manifest fallback to 404.html if found 60 | newManifest.fallback.id = uploadResult.id; 61 | } 62 | } catch (err) { 63 | console.error(`Error uploading file ${relativePath}:`, err); 64 | } 65 | } 66 | } catch (err) { 67 | console.error('ERROR:', err); 68 | } 69 | } 70 | } 71 | 72 | async function uploadManifest(manifest) { 73 | try { 74 | const manifestString = JSON.stringify(manifest); 75 | const uploadResult = await turbo.uploadFile({ 76 | fileStreamFactory: () => Readable.from(Buffer.from(manifestString)), 77 | fileSizeFactory: () => Buffer.byteLength(manifestString), 78 | signal: AbortSignal.timeout(10_000), 79 | dataItemOpts: { 80 | tags: [ 81 | { 82 | name: 'Content-Type', 83 | value: 'application/x.arweave-manifest+json', 84 | }, 85 | { 86 | name: 'App-Name', 87 | value: 'Permaweb-Deploy', 88 | }, 89 | ], 90 | }, 91 | }); 92 | return uploadResult.id; 93 | } catch (error) { 94 | console.error('Error uploading manifest:', error); 95 | return null; 96 | } 97 | } 98 | 99 | // starts processing files in the selected directory 100 | await processFiles(deployFolder); 101 | 102 | if (!newManifest.fallback.id) { 103 | // if no 404.html file is found, manifest fallback is set to the txId of index.html 104 | newManifest.fallback.id = newManifest.paths['index.html'].id; 105 | } 106 | 107 | const manifestId = await uploadManifest(newManifest); 108 | if (manifestId) { 109 | console.log(`Manifest uploaded with Id: ${manifestId}`); 110 | return manifestId; 111 | } 112 | } 113 | --------------------------------------------------------------------------------