diff --git a/README.md b/README.md index f5f0c8f..b8103d3 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ The CLI uses legacy ANS-104 bundlers for the default upload path, supports Hyper - **Arweave uploads:** Upload a folder or a single file. - **Names publishing:** Update a Permaweb Name after upload. +- **Permagit publishing:** Optionally publish the current committed Git `HEAD` to Permagit with the deployment wallet. - **Legacy bundler uploads:** Use legacy ANS-104 upload endpoints by default. - **HyperBEAM uploads:** Sign ANS-104 items and post them to a HyperBEAM bundler route. - **Arweave manifests:** Create manifest `0.2.0` documents for folder deployments, with SPA fallback detection. @@ -62,6 +63,12 @@ Deploy and update a Permaweb Name: permaweb-deploy deploy --use-names --name my-app --deploy-folder ./dist ``` +Deploy and publish the current Git `HEAD` to Permagit with the same Arweave JWK: + +```bash +permaweb-deploy deploy --deploy-folder ./dist --permagit my-app +``` + Or deploy and update a direct reference ID: ```bash @@ -105,6 +112,8 @@ Set the encoded value as `DEPLOY_KEY`, or pass `--wallet ./wallet.json`. Names updates currently require `--sig-type arweave`. Legacy references are signed as ANS-104 data items; carrier-backed names are signed as Arweave transactions. Ethereum, Polygon, and KYVE signers remain supported for upload-only flows. +Permagit publishing also requires `--sig-type arweave` and a Git executable. It publishes the current committed `HEAD`; it does not stage files, create a local commit, change the current branch, or add a Git remote. + Use a dedicated deployment wallet and make sure it has enough upload credits/balance for the selected legacy bundler. ## CLI Usage @@ -209,6 +218,8 @@ HyperBEAM uploads require an Arweave JWK signer. The default route is `/~bundler - `--sig-type, -s`: signer type. Choices: `arweave`, `ethereum`, `polygon`, `kyve`. Default: `arweave`. - `--wallet, -w`: path to wallet file. - `--private-key, -k`: private key or JWK JSON string. +- `--permagit`: publish the current committed Git `HEAD` to this Permagit repository after upload. +- `--permagit-ref`: Permagit branch name. Defaults to the current Git branch. - `--no-dedupe`: disable dedupe cache. - `--dedupe-cache-max-entries`: LRU cache size. Default: `10000`. - `--uploader`: bundler service base URL. Legacy uploads default to `https://up.arweave.net`; HyperBEAM uploads auto-discover an active PermawebOS uploader when omitted. @@ -342,6 +353,8 @@ permaweb-deploy/ - **`DEPLOY_KEY environment variable not set`:** pass `--wallet`, pass `--private-key`, or set `DEPLOY_KEY`. - **`Names updates currently require --sig-type arweave`:** use an Arweave JWK for names updates. +- **`--permagit requires an Arweave JWK`:** use `--sig-type arweave` and provide an Arweave JWK. +- **`--permagit must be run inside a Git repository`:** run the command from a checked-out repository. - **`Name [...] not found in namespace ...`:** verify the namespace manifest and spelling. - **`Name [...] is controlled by ..., not signer ...`:** use the wallet that controls the name. - **`signer is not reference authority`:** use the wallet that controls the target legacy reference. diff --git a/package.json b/package.json index 806a431..666ad7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@permaweb/deploy", - "version": "6.0.0", + "version": "6.1.0", "description": "Permaweb App Deployment Package", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 38799b1..1a3ee05 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -16,8 +16,25 @@ import { type DisplayRow, formatDisplayRows, formatUploadError } from '../utils/ import { hyperbeamBundlerLink } from '../utils/hyperbeam-uploader.js' import { preflightNamesUpdate, publishNamesUpdate } from '../utils/names.js' import { expandPath } from '../utils/path.js' +import { + type PermagitPublishResult, + preparePermagitTarget, + publishPermagit, +} from '../utils/permagit.js' import { runUploadWorkflow } from '../workflows/upload-workflow.js' +function appendPermagitRows(rows: DisplayRow[], result: PermagitPublishResult | undefined): void { + if (!result) return + + rows.push( + ['Permagit Repo', chalk.cyan(`arweave://${result.repoName}`)], + ['Permagit Ref', chalk.cyan(result.refName)], + ['Git Commit', chalk.green(result.commitSha)], + ['Permagit Pack ID', chalk.green(result.packTransactionId)], + ['Permagit Ref ID', chalk.green(result.refTransactionId)], + ) +} + export default class Deploy extends Command { static override args = {} @@ -28,6 +45,7 @@ export default class Deploy extends Command { '<%= config.bin %> deploy --wallet ./wallet.json --deploy-folder ./dist', '<%= config.bin %> deploy --wallet ./wallet.json --deploy-file ./dist/index.html', '<%= config.bin %> deploy --wallet ./wallet.json --uploader-type hyperbeam --uploader https://hyperbeam.example.com', + '<%= config.bin %> deploy --wallet ./wallet.json --permagit my-app', '<%= config.bin %> deploy --wallet ./wallet.json --use-names --name my-app', '<%= config.bin %> deploy --wallet ./wallet.json --use-names --reference-id REFERENCE_ID', ] @@ -49,6 +67,10 @@ export default class Deploy extends Command { interactive, })) as DeployConfig + if (baseConfig.permagit && baseConfig['sig-type'] !== 'arweave') { + this.error('--permagit requires an Arweave JWK (--sig-type arweave)') + } + let walletConfig: { privateKey?: string; wallet?: string } = { privateKey: baseConfig['private-key'], wallet: baseConfig.wallet, @@ -131,10 +153,24 @@ export default class Deploy extends Command { this.log(chalk.bold(chalk.cyan('\nStarting deployment...\n'))) try { + const permagitTarget = baseConfig.permagit + ? preparePermagitTarget(baseConfig.permagit, deployKey, baseConfig['permagit-ref']) + : undefined + let permagitResult: PermagitPublishResult | undefined + const upload = () => + runUploadWorkflow( + deployKey, + deployConfig, + { error: (message) => this.error(message) }, + permagitTarget + ? async (uploadClient) => { + permagitResult = await publishPermagit(permagitTarget, uploadClient) + } + : undefined, + ) + if (!deployConfig['use-names']) { - const uploadResult = await runUploadWorkflow(deployKey, deployConfig, { - error: (msg) => this.error(msg), - }) + const uploadResult = await upload() const txOrManifestId = uploadResult.transactionId const effectiveUploader = uploadResult.uploader ?? deployConfig.uploader @@ -161,6 +197,7 @@ export default class Deploy extends Command { rows.push(['Bundler link', chalk.yellow(bundlerLink)]) } + appendPermagitRows(rows, permagitResult) rows.push(['Arweave URL', chalk.yellow(`https://arweave.net/${txOrManifestId}`)]) this.log(chalk.bold(chalk.green('Deployment Successful!'))) @@ -196,9 +233,7 @@ export default class Deploy extends Command { spinner.succeed('Names target validated') - const uploadResult = await runUploadWorkflow(deployKey, deployConfig, { - error: (msg) => this.error(msg), - }) + const uploadResult = await upload() const txOrManifestId = uploadResult.transactionId const effectiveUploader = uploadResult.uploader ?? deployConfig.uploader @@ -239,6 +274,7 @@ export default class Deploy extends Command { rows.push(['Bundler link', chalk.yellow(bundlerLink)]) } + appendPermagitRows(rows, permagitResult) rows.push( ...(namesUpdate.name ? ([['Name', chalk.yellow(namesUpdate.name)]] as DisplayRow[]) : []), ['Names Target Kind', chalk.cyan(namesUpdate.kind)], diff --git a/src/constants/flags.ts b/src/constants/flags.ts index 73173f3..bdc19be 100644 --- a/src/constants/flags.ts +++ b/src/constants/flags.ts @@ -150,6 +150,17 @@ export const globalFlags = { required: false, }), }), + permagit: createFlagConfig({ + flag: Flags.string({ + description: 'Publish the current Git HEAD to the named Permagit repository after upload', + }), + }), + permagitRef: createFlagConfig({ + flag: Flags.string({ + dependsOn: ['permagit'], + description: 'Permagit branch name (defaults to the current Git branch)', + }), + }), privateKey: createFlagConfig({ flag: Flags.string({ char: 'k', @@ -234,6 +245,8 @@ export const deployFlags = { 'names-namespace': globalFlags.namesNamespace.flag, 'names-node': globalFlags.namesNode.flag, 'no-dedupe': globalFlags.noDedupe.flag, + permagit: globalFlags.permagit.flag, + 'permagit-ref': globalFlags.permagitRef.flag, 'private-key': globalFlags.privateKey.flag, 'reference-id': globalFlags.referenceId.flag, 'sig-type': globalFlags.sigType.flag, @@ -285,6 +298,8 @@ export interface DeployConfig { 'names-node'?: string 'no-dedupe': boolean 'private-key'?: string + permagit?: string + 'permagit-ref'?: string 'reference-id'?: string 'sig-type': string 'use-names': boolean @@ -313,6 +328,8 @@ export const deployFlagConfigs = { 'names-namespace': globalFlags.namesNamespace, 'names-node': globalFlags.namesNode, 'no-dedupe': globalFlags.noDedupe, + permagit: globalFlags.permagit, + 'permagit-ref': globalFlags.permagitRef, 'private-key': globalFlags.privateKey, 'reference-id': globalFlags.referenceId, 'sig-type': globalFlags.sigType, diff --git a/src/utils/__tests__/permagit.test.ts b/src/utils/__tests__/permagit.test.ts new file mode 100644 index 0000000..be11226 --- /dev/null +++ b/src/utils/__tests__/permagit.test.ts @@ -0,0 +1,141 @@ +import { createHash } from 'node:crypto' + +import { describe, expect, it } from 'vitest' + +import { TEST_ARWEAVE_WALLET } from '../../../tests/constants.js' +import type { UploadClient, UploadFileArgs } from '../hyperbeam-uploader.js' +import { preparePermagitTarget, publishPermagit } from '../permagit.js' + +const DEPLOY_KEY = Buffer.from(JSON.stringify(TEST_ARWEAVE_WALLET)).toString('base64') + +interface CapturedUpload { + data: Buffer + id: string + tags: Record +} + +function mockUploadClient(): { client: UploadClient; uploads: CapturedUpload[] } { + const uploads: CapturedUpload[] = [] + const client: UploadClient = { + async uploadFile(args: UploadFileArgs) { + const data = Buffer.isBuffer(args.file) ? args.file : Buffer.from('') + const id = `upload-${uploads.length + 1}` + uploads.push({ + data, + id, + tags: Object.fromEntries( + (args.dataItemOpts?.tags ?? []).map((tag) => [tag.name, tag.value]), + ), + }) + return { id } + }, + } + + return { client, uploads } +} + +function target(repoName = 'my-app', branch = 'main') { + return preparePermagitTarget(repoName, DEPLOY_KEY, branch) +} + +describe('Permagit publishing', () => { + it('prepares the current committed HEAD without changing the repository', () => { + const prepared = target('my-app', 'test-branch') + + expect(prepared.branch).toBe('test-branch') + expect(prepared.commitSha).toMatch(/^[\da-f]{40}$/) + expect(prepared.gitRoot).toBe(process.cwd()) + expect(prepared.ownerAddress).toBe( + createHash('sha256') + .update(Buffer.from(TEST_ARWEAVE_WALLET.n, 'base64url')) + .digest('base64url'), + ) + }) + + it('uploads a Permagit-compatible full pack and ref', async () => { + const prepared = target() + const { client, uploads } = mockUploadClient() + + const result = await publishPermagit(prepared, client, 10 * 1024 * 1024) + + expect(uploads).toHaveLength(2) + expect(uploads[0].data.subarray(0, 4).toString('ascii')).toBe('PACK') + expect(uploads[0].tags).toEqual({ + 'App-Name': 'permagit', + 'Content-Type': 'application/x-git-pack', + Owner: prepared.ownerAddress, + 'Pack-Type': 'full', + Repo: 'my-app', + Type: 'pack', + }) + expect(JSON.parse(uploads[1].data.toString('utf8'))).toEqual({ + old: '0'.repeat(40), + packTx: uploads[0].id, + ref: 'refs/heads/main', + snapshotTx: null, + target: prepared.commitSha, + }) + expect(uploads[1].tags).toEqual({ + 'App-Name': 'permagit', + Owner: prepared.ownerAddress, + 'Pack-Tx': uploads[0].id, + 'Ref-Name': 'refs/heads/main', + 'Ref-Old': '0'.repeat(40), + 'Ref-Target': prepared.commitSha, + Repo: 'my-app', + Type: 'ref', + }) + expect(result.packTransactionId).toBe(uploads[0].id) + expect(result.refTransactionId).toBe(uploads[1].id) + }) + + it('uses Permagit pack chunks and a pack manifest for larger packs', async () => { + const prepared = target('chunked-app') + const { client, uploads } = mockUploadClient() + + const result = await publishPermagit(prepared, client, 512 * 1024) + + const chunks = uploads.filter((upload) => upload.tags.Type === 'pack-chunk') + const manifest = uploads.find( + (upload) => upload.tags.Type === 'pack' && upload.tags['Pack-Type'] === 'chunked', + ) + expect(chunks.length).toBeGreaterThan(1) + expect(manifest).toBeDefined() + const group = manifest!.tags['Pack-Group'] + expect(JSON.parse(manifest!.data.toString('utf8'))).toEqual({ + chunks: chunks.map((chunk) => chunk.id), + group, + totalSize: chunks.reduce((total, chunk) => total + chunk.data.byteLength, 0), + type: 'pack-manifest', + }) + expect(chunks[0].tags).toEqual({ + 'App-Name': 'permagit', + 'Chunk-Index': '0', + 'Chunk-Total': String(chunks.length), + 'Content-Type': 'application/octet-stream', + Owner: prepared.ownerAddress, + 'Pack-Group': group, + 'Pack-Type': 'full', + Repo: 'chunked-app', + Type: 'pack-chunk', + }) + expect(manifest!.tags).toEqual({ + 'App-Name': 'permagit', + 'Chunk-Total': String(chunks.length), + 'Content-Type': 'application/json', + Owner: prepared.ownerAddress, + 'Pack-Group': group, + 'Pack-Type': 'chunked', + Repo: 'chunked-app', + Type: 'pack', + }) + expect(result.packTransactionId).toBe(manifest!.id) + }) + + it('rejects invalid repository names and wallet material', () => { + expect(() => preparePermagitTarget('bad repo', DEPLOY_KEY)).toThrow('Permagit repository name') + expect(() => + preparePermagitTarget('my-app', Buffer.from('{}').toString('base64'), 'main'), + ).toThrow('--permagit requires a valid Arweave JWK') + }) +}) diff --git a/src/utils/permagit.ts b/src/utils/permagit.ts new file mode 100644 index 0000000..b9de6cd --- /dev/null +++ b/src/utils/permagit.ts @@ -0,0 +1,215 @@ +import { execFileSync } from 'node:child_process' +import { createHash, randomBytes } from 'node:crypto' + +import ora from 'ora' + +import type { UploadClient } from './hyperbeam-uploader.js' + +const CHUNK_SIZE = 100 * 1024 +const ZERO_SHA = '0'.repeat(40) +const REPO_NAME = /^[\dA-Za-z][\w.-]{0,127}$/ + +export interface PermagitTarget { + branch: string + commitSha: string + gitRoot: string + ownerAddress: string + repoName: string +} + +export interface PermagitPublishResult extends PermagitTarget { + packTransactionId: string + refName: string + refTransactionId: string +} + +function git(args: string[], cwd: string, input?: string): Buffer { + try { + return execFileSync('git', args, { + cwd, + input, + maxBuffer: 1024 ** 3, + timeout: 10 * 60 * 1000, + }) + } catch (error) { + const stderr = String((error as { stderr?: unknown }).stderr ?? '').trim() + throw new Error(stderr || `git ${args.join(' ')} failed`) + } +} + +const gitText = (args: string[], cwd: string): string => git(args, cwd).toString().trim() + +function jwkOwner(deployKey: string): string { + try { + const jwk = JSON.parse(Buffer.from(deployKey, 'base64').toString()) as Record + if (jwk.kty !== 'RSA' || typeof jwk.n !== 'string' || typeof jwk.d !== 'string') + throw new TypeError('Invalid RSA JWK') + return createHash('sha256').update(Buffer.from(jwk.n, 'base64url')).digest('base64url') + } catch { + throw new Error('--permagit requires a valid Arweave JWK') + } +} + +function resolveBranch(gitRoot: string, requested?: string): string { + if (requested !== undefined && !requested.trim()) + throw new Error('--permagit-ref cannot be empty') + + let branch = requested + if (branch === undefined) { + try { + branch = gitText(['symbolic-ref', '--quiet', '--short', 'HEAD'], gitRoot) + } catch { + throw new Error('Cannot determine the current Git branch; pass --permagit-ref explicitly') + } + } + + branch = branch?.trim().replace(/^refs\/heads\//, '') + if (!branch) { + throw new Error('Cannot determine the current Git branch; pass --permagit-ref explicitly') + } + + try { + git(['check-ref-format', `refs/heads/${branch}`], gitRoot) + } catch { + throw new Error(`Invalid Permagit branch: ${branch}`) + } + + return branch +} + +export function preparePermagitTarget( + repoName: string, + deployKey: string, + branch?: string, + cwd = process.cwd(), +): PermagitTarget { + if (!REPO_NAME.test(repoName)) { + throw new Error( + 'Permagit repository name must be 1-128 characters using letters, numbers, dot, underscore, or hyphen', + ) + } + + const ownerAddress = jwkOwner(deployKey) + + let gitRoot: string + try { + gitRoot = gitText(['rev-parse', '--show-toplevel'], cwd) + } catch { + throw new Error('--permagit must be run inside a Git repository') + } + + const commitSha = gitText(['rev-parse', '--verify', 'HEAD^{commit}'], gitRoot) + // Permagit currently parses SHA-1 object IDs throughout its clients. + // eslint-disable-next-line unicorn/better-regex + if (!/^[0-9a-f]{40}$/i.test(commitSha)) { + throw new Error('Permagit currently requires a SHA-1 Git repository') + } + + return { + branch: resolveBranch(gitRoot, branch), + commitSha, + gitRoot, + ownerAddress, + repoName, + } +} + +export async function publishPermagit( + target: PermagitTarget, + uploadClient: UploadClient, + chunkSizeBytes = CHUNK_SIZE, +): Promise { + if (!Number.isSafeInteger(chunkSizeBytes) || chunkSizeBytes <= 0) { + throw new Error('Permagit chunk size must be a positive integer') + } + + const spinner = ora('Creating Permagit pack').start() + const upload = async (data: Buffer, tags: Record): Promise => { + const allTags = { + 'App-Name': 'permagit', + Owner: target.ownerAddress, + Repo: target.repoName, + ...tags, + } + const { id } = await uploadClient.uploadFile({ + dataItemOpts: { + tags: Object.entries(allTags).map(([name, value]) => ({ name, value })), + }, + file: data, + }) + if (!id) throw new Error('Permagit upload did not return a transaction ID') + return id + } + + try { + const pack = git( + ['pack-objects', '--stdout', '--revs'], + target.gitRoot, + `${target.commitSha}\n`, + ) + spinner.text = 'Publishing Permagit pack' + let packTransactionId: string + if (pack.byteLength <= chunkSizeBytes) { + packTransactionId = await upload(pack, { + 'Content-Type': 'application/x-git-pack', + 'Pack-Type': 'full', + Type: 'pack', + }) + } else { + const group = randomBytes(12).toString('hex') + const total = Math.ceil(pack.byteLength / chunkSizeBytes) + const chunks: string[] = [] + + for (let index = 0; index < total; index += 1) { + spinner.text = `Publishing Permagit pack chunk ${index + 1}/${total}` + chunks.push( + await upload(pack.subarray(index * chunkSizeBytes, (index + 1) * chunkSizeBytes), { + 'Chunk-Index': String(index), + 'Chunk-Total': String(total), + 'Content-Type': 'application/octet-stream', + 'Pack-Group': group, + 'Pack-Type': 'full', + Type: 'pack-chunk', + }), + ) + } + + spinner.text = 'Publishing Permagit pack manifest' + packTransactionId = await upload( + Buffer.from( + JSON.stringify({ chunks, group, totalSize: pack.byteLength, type: 'pack-manifest' }), + ), + { + 'Chunk-Total': String(total), + 'Content-Type': 'application/json', + 'Pack-Group': group, + 'Pack-Type': 'chunked', + Type: 'pack', + }, + ) + } + + const refName = `refs/heads/${target.branch}` + const ref = { + old: ZERO_SHA, + packTx: packTransactionId, + ref: refName, + snapshotTx: null, + target: target.commitSha, + } + spinner.text = 'Publishing Permagit ref' + const refTransactionId = await upload(Buffer.from(JSON.stringify(ref)), { + 'Pack-Tx': packTransactionId, + 'Ref-Name': refName, + 'Ref-Old': ZERO_SHA, + 'Ref-Target': target.commitSha, + Type: 'ref', + }) + + spinner.succeed(`Permagit ref published (${target.repoName}/${target.branch})`) + return { ...target, packTransactionId, refName, refTransactionId } + } catch (error) { + spinner.fail('Permagit publication failed') + throw error + } +} diff --git a/src/workflows/upload-workflow.ts b/src/workflows/upload-workflow.ts index 50b18b8..a26b9ff 100644 --- a/src/workflows/upload-workflow.ts +++ b/src/workflows/upload-workflow.ts @@ -100,12 +100,14 @@ async function discoverUsableHyperbeamUploader(spinner: ReturnType): * @param deployKey - Wallet material (base64 JWK or hex private key per sig-type) * @param config - Upload paths, dedupe, and bundler service URL. * @param io - Error handler (must exit the process) + * @param afterUpload - Optional callback that reuses the initialized uploader. * @returns Transaction ID or folder manifest ID */ export async function runUploadWorkflow( deployKey: string, config: UploadWorkflowConfig, io: UploadWorkflowIo, + afterUpload?: (uploadClient: UploadClient) => Promise, ): Promise { const spinner = ora() @@ -239,6 +241,8 @@ export async function runUploadWorkflow( io.error(`Upload failed: ${errorMessage}`) } + await afterUpload?.(uploadClient) + return { cost, size, diff --git a/tests/e2e/deploy-command.test.ts b/tests/e2e/deploy-command.test.ts index 57e785d..703f376 100644 --- a/tests/e2e/deploy-command.test.ts +++ b/tests/e2e/deploy-command.test.ts @@ -123,6 +123,71 @@ describe( expect(result.error).toBeUndefined() }) + it('should reject Permagit publishing with a non-Arweave signer', async () => { + const result = await runCommand([ + 'deploy', + '--deploy-file', + './tests/fixtures/test-app/index.html', + '--private-key', + TEST_ETH_PRIVATE_KEY, + '--sig-type', + 'ethereum', + '--permagit', + 'my-app', + ]) + + expect(result.error).toBeDefined() + expect(result.error?.message).toContain( + '--permagit requires an Arweave JWK (--sig-type arweave)', + ) + }) + + it('should publish the current Git HEAD to Permagit with the deployment JWK', async () => { + const deploymentId = 'd'.repeat(43) + const permagitId = 'p'.repeat(43) + const permagitUploads: Record[] = [] + + server.use( + http.post(`${DEFAULT_LEGACY_UPLOADER}/v1/tx/arweave`, async ({ request }) => { + const raw = Buffer.from(await request.arrayBuffer()) + const tags = dataItemTags(raw) + if (tags['App-Name'] === 'permagit') { + permagitUploads.push(tags) + return HttpResponse.json({ id: permagitId }) + } + + return HttpResponse.json({ id: deploymentId }) + }), + ) + + const result = await runCommand([ + 'deploy', + '--deploy-file', + './tests/fixtures/test-app/index.html', + '--wallet', + './tests/fixtures/test_wallet.json', + '--no-dedupe', + '--permagit', + 'my-app', + '--permagit-ref', + 'permagit-test', + ]) + + expect(result.error).toBeUndefined() + expect(permagitUploads.some((tags) => tags.Type === 'pack')).toBe(true) + expect(permagitUploads.some((tags) => tags.Type === 'pack-chunk')).toBe(true) + expect(permagitUploads.find((tags) => tags.Type === 'ref')).toEqual({ + 'App-Name': 'permagit', + Owner: walletAddress(TEST_ARWEAVE_WALLET), + 'Pack-Tx': permagitId, + 'Ref-Name': 'refs/heads/permagit-test', + 'Ref-Old': '0'.repeat(40), + 'Ref-Target': expect.stringMatching(/^[\da-f]{40}$/), + Repo: 'my-app', + Type: 'ref', + }) + }) + it('should deploy without requiring names publishing by default', async () => { const result = await runCommand([ 'deploy',