diff --git a/bin/cliOperations.ts b/bin/cliOperations.ts index 9ff41eef5..2cbe05ade 100644 --- a/bin/cliOperations.ts +++ b/bin/cliOperations.ts @@ -8,6 +8,7 @@ import * as terms from '../utility/hdbTerms.ts'; import { httpRequest } from '../utility/common_utils.ts'; import * as path from 'path'; import * as fs from 'fs-extra'; +import { execFileSync } from 'node:child_process'; import * as YAML from 'yaml'; import { streamPackagedDirectory, packageDirectory, scanPackageDirectory } from '../components/packageComponent.ts'; import { encode as encodeCbor } from 'cbor-x'; @@ -36,6 +37,11 @@ const TRANSPORT_ONLY_FIELDS = new Set([ 'json', 'skip_node_modules', 'skip_symlinks', + // deploy-by-reference opt-in: consumed client-side to build `package` (and derive `credentials`), + // never sent to the server. (`credentials`, plural, IS a real operation field and is sent.) + 'by_ref', + 'ref', + 'credential', ]); // Streaming (multipart upload + SSE progress) deploy was introduced in 5.1.0. A CLI at >= @@ -96,13 +102,124 @@ function operationFields(req: any): any { return fields; } -export { cliOperations, buildRequest }; +export { cliOperations, buildRequest, resolveGitCommittish, deriveGitSecretName }; + +// --- deploy-by-reference (opt-in via `by_ref=true` / `ref=`) ---------------------- +// Resolve the app's GitHub repo + commit from the local working copy (or GitHub Actions env) so +// `harper deploy by_ref=true` deploys a pinned commit by reference instead of uploading a payload +// blob. This is deliberately client-side: only the runner has the git context. The no-flag default +// stays the payload deploy, so this changes nothing for existing callers. + +function runGit(args: string[]): string { + return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); +} + +function resolveGitRepo(): string { + if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY; + let url: string; + try { + url = runGit(['remote', 'get-url', 'origin']); + } catch { + throw new Error( + 'deploy by_ref: no git `origin` remote found — push this project to GitHub, or pass an explicit package=.' + ); + } + // git@github.com:owner/repo.git | https://github.com/owner/repo(.git) | ssh://… + const match = url.match(/github\.com[:/]+([^/]+\/[^/]+?)(?:\.git)?\/?$/i); + if (!match) throw new Error(`deploy by_ref: could not parse owner/repo from the origin remote: ${url}`); + return match[1]; +} + +function resolveGitCommittish(ref: unknown): string { + // buildRequest JSON-parses CLI args, so a numeric ref (e.g. `ref=1234567`) arrives as a number — + // coerce it back to a string rather than silently ignoring it and falling back to HEAD. + const refStr = typeof ref === 'string' || typeof ref === 'number' ? String(ref).trim() : ''; + if (refStr.length > 0) return refStr; // explicit ref= wins + if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA; + try { + return runGit(['rev-parse', 'HEAD']); + } catch { + throw new Error('deploy by_ref: could not resolve HEAD — make at least one commit, or pass ref=.'); + } +} + +function warnIfWorkingTreeDirty(): void { + try { + if (runGit(['status', '--porcelain'])) { + process.stderr.write( + 'warning: working tree has uncommitted changes — the cluster deploys the committed (and pushed) commit, so those changes are NOT included.\n' + ); + } + } catch { + // Not a git repo; resolveGitRepo/resolveGitCommittish will surface a clearer error. + } +} + +function defaultProjectName(projectPath: string): string { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectPath, 'package.json'), 'utf8')); + if (typeof pkg.name === 'string' && pkg.name.length > 0) return pkg.name.replace(/^@[^/]+\//, ''); + } catch { + // No/invalid package.json — fall back to the directory name. + } + return path.basename(projectPath); +} + +// Mirror the server's `deriveGitSecretName` (secretOperations.ts) EXACTLY so the reference this +// attaches matches the row `harper deploy setup=true` sealed and a literal-token deploy would use: +// `deploy..git.` (git hosts get a `.git.` segment; host via normalizeGitHost). +function deriveGitSecretName(component: string, host: string): string { + const hostPart = host + .trim() + .replace(/^[a-z0-9+.-]+:\/\//i, '') + .replace(/^\/\//, '') + .replace(/\/.*$/, '') + .replace(/^[^@]*@/, '') + .toLowerCase() + .replace(/[^\w.-]+/g, '_'); + const componentPart = String(component).replace(/[^\w.-]+/g, '_'); + return `deploy.${componentPart}.git.${hostPart}`; +} + +// Opt-in deploy-by-reference: resolve the pinned git ref (+ optional sealed credential) onto `req` — +// a `git+https` package pinned by SHA, plus a `credentials` reference when `credential=` is set. +// Exported for unit tests. Reads the repo/committish from GITHUB_REPOSITORY/GITHUB_SHA or the local +// `origin` remote + HEAD. +export function prepareDeployByRef(req: any): void { + const repo = resolveGitRepo(); + const committish = resolveGitCommittish(req.ref); + warnIfWorkingTreeDirty(); + if (!req.project) req.project = defaultProjectName(process.cwd()); + // git+https (not ssh): a private clone is authenticated by a git-host token credential (#1799), + // which rides over HTTPS. A public repo needs no credential at all. + req.package = `git+https://github.com/${repo}.git#${committish}`; + // `credential=github.com` (or `credential=true`) attaches the sealed-token reference the cluster + // resolves at fetch time — provision it once with `harper deploy setup=true`. + const credentialHost = + req.credential === true + ? 'github.com' + : typeof req.credential === 'string' && req.credential.length > 0 + ? req.credential + : undefined; + if (credentialHost && req.credentials === undefined) { + req.credentials = [{ host: credentialHost, secret: deriveGitSecretName(req.project, credentialHost) }]; + } + process.stderr.write(`Deploying "${req.project}" by reference: ${req.package}\n`); +} + const PREPARE_OPERATION: any = { deploy_component: async (req) => { if (req.package) { return; } + // Opt-in: deploy a pinned git commit by reference instead of packaging the working directory. + // Templates scaffold `by_ref=true`; without the flag the payload path below is unchanged. + if (req.by_ref || req.ref) { + prepareDeployByRef(req); + return; + } + const projectPath = process.cwd(); if (!req.project) req.project = path.basename(projectPath); const packageOptions = { diff --git a/unitTests/bin/cliOperations.test.js b/unitTests/bin/cliOperations.test.js index 82c70e22d..d4bbbcd56 100644 --- a/unitTests/bin/cliOperations.test.js +++ b/unitTests/bin/cliOperations.test.js @@ -231,3 +231,61 @@ describe('cliOperations', () => { }); }); }); + +describe('deploy by reference (by_ref)', () => { + const { prepareDeployByRef, resolveGitCommittish, deriveGitSecretName } = cliOperationsModule; + let savedRepo, savedSha, savedStderrWrite; + + beforeEach(() => { + savedRepo = process.env.GITHUB_REPOSITORY; + savedSha = process.env.GITHUB_SHA; + // Resolve deterministically from env so the tests never shell out to git. + process.env.GITHUB_REPOSITORY = 'acme/demo'; + process.env.GITHUB_SHA = 'abc123def456'; + // Silence the "Deploying … by reference" line prepareDeployByRef writes to stderr. + savedStderrWrite = process.stderr.write; + process.stderr.write = () => true; + }); + + afterEach(() => { + if (savedRepo === undefined) delete process.env.GITHUB_REPOSITORY; + else process.env.GITHUB_REPOSITORY = savedRepo; + if (savedSha === undefined) delete process.env.GITHUB_SHA; + else process.env.GITHUB_SHA = savedSha; + process.stderr.write = savedStderrWrite; + }); + + it('builds a git+https package pinned to the resolved SHA', () => { + const req = { by_ref: true, project: 'demo' }; + prepareDeployByRef(req); + assert.strictEqual(req.package, 'git+https://github.com/acme/demo.git#abc123def456'); + assert.strictEqual(req.credentials, undefined); // no credential requested + }); + + it('an explicit ref= wins over GITHUB_SHA', () => { + const req = { by_ref: true, ref: 'v9.9.9', project: 'demo' }; + prepareDeployByRef(req); + assert.strictEqual(req.package, 'git+https://github.com/acme/demo.git#v9.9.9'); + }); + + it('resolveGitCommittish coerces a numeric ref to a string (buildRequest JSON-parses it)', () => { + assert.strictEqual(resolveGitCommittish(1234567), '1234567'); + assert.strictEqual(resolveGitCommittish('v1.0.0'), 'v1.0.0'); + }); + + it('credential=true attaches a github.com credential reference', () => { + const req = { by_ref: true, credential: true, project: 'demo' }; + prepareDeployByRef(req); + assert.deepStrictEqual(req.credentials, [{ host: 'github.com', secret: 'deploy.demo.git.github.com' }]); + }); + + it('credential= attaches a credential reference for that host', () => { + const req = { by_ref: true, credential: 'github.com', project: 'my-app' }; + prepareDeployByRef(req); + assert.deepStrictEqual(req.credentials, [{ host: 'github.com', secret: 'deploy.my-app.git.github.com' }]); + }); + + it('deriveGitSecretName matches the server convention (deploy..git.)', () => { + assert.strictEqual(deriveGitSecretName('my-app', 'github.com'), 'deploy.my-app.git.github.com'); + }); +});