diff --git a/.changeset/workflow-run-ref-branch-name.md b/.changeset/workflow-run-ref-branch-name.md new file mode 100644 index 0000000000..8d9985abd7 --- /dev/null +++ b/.changeset/workflow-run-ref-branch-name.md @@ -0,0 +1,5 @@ +--- +'eas-cli': patch +--- + +Keep the fully qualified branch or tag name in `eas workflow:run --ref`, so a run is labeled with the ref that was requested instead of the commit hash it resolved to locally, and warn when the remote has that ref at a different commit. diff --git a/packages/eas-cli/src/commands/workflow/__tests__/run.test.ts b/packages/eas-cli/src/commands/workflow/__tests__/run.test.ts index 0b6d16ea13..cadbfc5fb4 100644 --- a/packages/eas-cli/src/commands/workflow/__tests__/run.test.ts +++ b/packages/eas-cli/src/commands/workflow/__tests__/run.test.ts @@ -3,7 +3,7 @@ import { parseJsonInputs, parseWorkflowInputsFromYaml, } from '../../../commandUtils/workflow/inputs'; -import { resolveWorkflowRunSshInput } from '../run'; +import { resolveWorkflowRunGitRef, resolveWorkflowRunSshInput } from '../run'; describe(resolveWorkflowRunSshInput, () => { it('is null when --ssh is not set', () => { @@ -29,6 +29,35 @@ describe(resolveWorkflowRunSshInput, () => { }); }); +describe(resolveWorkflowRunGitRef, () => { + const commitSha = '0e5c2f18c7e4d0e2f5a1b3c4d5e6f708192a3b4c'; + + it('sends a confirmed branch ref instead of the commit it points at', () => { + expect( + resolveWorkflowRunGitRef({ canonicalRefName: 'refs/heads/my-branch', commitSha }) + ).toBe('refs/heads/my-branch'); + }); + + it('sends a confirmed tag ref instead of the commit it points at', () => { + expect( + resolveWorkflowRunGitRef({ canonicalRefName: 'refs/tags/v1.0.0', commitSha }) + ).toBe('refs/tags/v1.0.0'); + }); + + it('keeps a branch name containing slashes whole', () => { + expect( + resolveWorkflowRunGitRef({ canonicalRefName: 'refs/heads/feature/my-branch', commitSha }) + ).toBe('refs/heads/feature/my-branch'); + }); + + it('sends the commit when nothing named a ref', () => { + // `HEAD`, `@`, a detached `HEAD`, a revision expression such as `HEAD~2`, a commit + // hash, a remote-tracking ref, and a name whose casing the ref store rejected all + // arrive here the same way: the ref-name lookup could not confirm one. + expect(resolveWorkflowRunGitRef({ canonicalRefName: null, commitSha })).toBe(commitSha); + }); +}); + describe('parseInputs', () => { it('should parse single key=value pair', () => { const inputs = parseInputs(['key=value']); diff --git a/packages/eas-cli/src/commands/workflow/run.ts b/packages/eas-cli/src/commands/workflow/run.ts index 8ba5787ed3..4d2e95b1cb 100644 --- a/packages/eas-cli/src/commands/workflow/run.ts +++ b/packages/eas-cli/src/commands/workflow/run.ts @@ -91,6 +91,81 @@ export function resolveWorkflowRunSshInput({ return ssh ? { idleTimeoutSeconds } : null; } +/** + * Pick the value to send as `gitRef` when running a workflow from `--ref`. + * + * The server resolves the ref itself and records it as the run's `requestedGitRef`, + * which is what the website shows in the "Branch" column and offers in the Git branch + * filter. Resolving the ref to a commit locally before dispatching therefore throws + * away the branch or tag name the user asked for. + * + * A ref is sent fully qualified (`refs/heads/my-branch`), which is the form the server + * already stores -- see `normalizeGitRef` in `workflow/insights/filters.ts`, which + * qualifies a bare name before filtering runs by it. It is also the form that says which + * ref was meant when a tag and a branch share a name. Anything that does not name a ref + * keeps being sent as the locally resolved commit, because it either has no meaning in + * the remote repository or does not mean the same thing there: `HEAD` and `@` resolve to + * the default branch server-side, revision expressions such as `HEAD~2` are not refs at + * all, and remote-tracking refs like `origin/my-branch` are local names for remote + * branches. + */ +export function resolveWorkflowRunGitRef({ + canonicalRefName, + commitSha, +}: { + canonicalRefName: string | null; + commitSha: string; +}): string { + return canonicalRefName ?? commitSha; +} + +/** + * The fully qualified ref name (e.g. `refs/heads/my-branch`) that `ref` points at, spelled + * the way the ref store spells it, or null when `ref` does not unambiguously name one. + * + * The name is confirmed against the ref store rather than trusted, because + * `git rev-parse --symbolic-full-name` echoes the casing the caller typed: on a + * case-insensitive filesystem `My-Branch` resolves to the loose ref for `my-branch` and + * comes back as `refs/heads/My-Branch`. Remote ref names are case-sensitive, so sending + * that would fail a run that used to work. + */ +async function maybeResolveCanonicalRefNameAsync( + ref: string, + projectDir: string +): Promise { + let symbolicFullName: string; + try { + const { output } = await spawnAsync( + 'git', + ['rev-parse', '--symbolic-full-name', '--verify', ref], + { cwd: projectDir } + ); + symbolicFullName = output[0].trim(); + } catch (err) { + // `--verify` exits 0 with empty output for a revision expression, so this is a real + // git or environment failure rather than "not a ref". Falling back to the commit is + // still right, but it should not be silent. + Log.debug(`Could not resolve the full ref name for "${ref}": ${err}`); + return null; + } + if (!symbolicFullName) { + return null; + } + try { + const { output } = await spawnAsync( + 'git', + ['for-each-ref', '--count=1', '--format=%(refname)', symbolicFullName], + { cwd: projectDir } + ); + // `for-each-ref` matches a pattern up to a slash as well, so `refs/heads/feat` would + // report `refs/heads/feat/alpha`. Require the name back exactly. + return output[0].trim() === symbolicFullName ? symbolicFullName : null; + } catch (err) { + Log.debug(`Could not confirm "${symbolicFullName}" against the ref store: ${err}`); + return null; + } +} + export default class WorkflowRun extends EasCommand { static override description = 'run an EAS workflow. The entire local project directory will be packaged and uploaded to EAS servers for the workflow run, unless the --ref flag is used.'; @@ -183,16 +258,26 @@ export default class WorkflowRun extends EasCommand { if (flags.ref) { // Run from git ref const fileName = path.basename(args.file); - // Find the real commit, make sure the ref is valid - gitRef = ( - await spawnAsync('git', ['rev-parse', flags.ref], { + // Find the real commit, make sure the ref is valid. The ref-name lookup swallows its + // own failures, so this can only reject from `rev-parse`, with the same error as before. + const [revParseResult, canonicalRefName] = await Promise.all([ + spawnAsync('git', ['rev-parse', flags.ref], { cwd: projectDir, - }) - ).output[0].trim(); - if (!gitRef) { + }), + maybeResolveCanonicalRefNameAsync(flags.ref, projectDir), + ]); + const commitSha = revParseResult.output[0].trim(); + if (!commitSha) { throw new Error('Failed to resolve git reference'); } - Log.log(`Using workflow file ${fileName} at ${gitRef}`); + // Send the branch or tag name the user asked for, so that the server records it as + // the run's requested git ref instead of a bare commit hash. + gitRef = resolveWorkflowRunGitRef({ canonicalRefName, commitSha }); + Log.log( + gitRef === commitSha + ? `Using workflow file ${fileName} at ${gitRef}` + : `Using workflow file ${fileName} at ${gitRef} (locally ${commitSha})` + ); let revisionResult: WorkflowRevision | undefined; try { revisionResult = await WorkflowRevisionMutation.getOrCreateWorkflowRevisionFromGitRefAsync( @@ -214,6 +299,19 @@ export default class WorkflowRun extends EasCommand { `Failed to find or create workflow revision for ${fileName} at ${flags.ref}` ); } + // The server resolves the ref against the remote, so a ref name can land on a commit + // the user does not have locally -- unpushed work, or a branch that moved. Say so + // rather than letting the commit printed above be the only one they ever see. + if ( + gitRef !== commitSha && + revisionResult.commitSha && + revisionResult.commitSha !== commitSha + ) { + Log.warn( + `${gitRef} is at ${commitSha} locally but ${revisionResult.commitSha} on the remote. ` + + 'The run uses the remote commit; push your local commits first if you meant those.' + ); + } yamlConfig = revisionResult.yamlConfig; workflowRevisionId = revisionResult.id; } else {